Skip to content

Style and brand

Evolve components set no brand colors, radii or sizes of their own. They read design tokens: CSS custom properties that your brand stylesheet defines. Change a token and every component that reads it follows, across all feature packages.

The create shopping list dialog with the default Evolve brand

The same dialog after a brand changed only its tokens: navy text, blue primary button

The second dialog is the same component. Only the token values changed.

Your brand is one stylesheet that you own. In the reference sites it is src/app/global.css, imported once by the root layout (src/app/layout.tsx):

import "./global.css";

The stylesheet starts with Tailwind, then the styles.css of every Evolve package you install:

@import "tailwindcss";
@import "@evolve-storefront/ui/styles.css";
@import "@evolve-storefront/commerce-ui/styles.css";
@import "@evolve-storefront/layout/styles.css";
@import "@evolve-storefront/feature-cart/styles.css";
@import "@evolve-storefront/feature-order/styles.css";
/* … one line per feature package you install */

Each styles.css registers that package’s source with Tailwind (@source "./src").

@evolve-storefront/ui ships the default Evolve brand as global.css. It is not a package export, because your brand is yours to own. Copy its @theme block and its [data-surface] rules into your stylesheet, then change the values:

cat node_modules/@evolve-storefront/ui/global.css

Set tokens in @theme, as plain values. Do not write var() aliases there: an alias resolves once at :root, so you can never change it for a part of the page later (see Change a token for one region).

Color roles come in two tiers. Surface roles describe the plane a component sits on, and change together when the plane changes. Brand roles describe your actions and identity, and stay the same on every plane.

Tier Tokens
Surface --color-background, --color-foreground, --color-muted, --color-muted-foreground, --color-border, --color-input, --color-ring, --color-highlight, --color-highlight-foreground
Brand --color-primary, --color-secondary, --color-brand, --color-destructive, each with a -foreground

A few roles need a word:

  • primary is your action color: the main button.
  • brand is your identity color: active navigation, sale ribbons, step indicators. Use it when your logo color differs from your button color.
  • highlight is the hover and focus plane of secondary and tertiary buttons, menu items and select options.
  • input draws the boundary of form controls. border draws dividers and card edges only.
@theme {
--color-primary: hsl(240 5.9% 10%);
--color-primary-foreground: hsl(0 0% 98%);
/* The identity color. Dark text, because white on this orange is 2.99:1. */
--color-brand: #ff621f;
--color-brand-foreground: hsl(240 10% 3.9%);
}
Token Read by
--radius-base Controls: buttons, inputs, textareas, select triggers, callouts
--radius-sm, --radius-md, --radius-lg Surfaces such as cards and dialogs

--control-height, --control-padding-x and --control-font-size size buttons, inputs, textareas and select triggers together. One declaration sizes a whole form, and you can change it per breakpoint:

@media (width >= 48rem) {
:root {
--control-height: 3rem;
--control-padding-x: 1.25rem;
--control-font-size: 1rem;
}
}

Components have no size prop. Size is a token, so it can change at a breakpoint or in a region.

Token What it sets If you leave it out
--flow-gap Gap between stacked children in dialogs, sheets and form fields No gap
--container-max, --container-padding Content width and page gutters of Container No max width, no gutters
--sheet-width, --sheet-height Size of a Sheet panel. Set them on :root: the sheet renders outside your page tree The panel sizes to its content
--detail-aside-width The summary column on cart, order and quote detail pages 24rem
--cart-summary-top Sticky offset of the cart summary 1rem
--content-block-gap Vertical rhythm between CMS content blocks 3rem

Only the last three have a fallback in the packages. The color, radius and control tokens have no fallback either, so start from the default brand’s full @theme and change values rather than writing a partial one.

--font-sans is the font token. Evolve ships no type scale: Heading only turns a level into an h1–h6 tag, with no classes. Style headings in your base layer, or Tailwind’s reset leaves them at body size:

@layer base {
h1 { @apply font-bold text-3xl md:text-4xl; }
h2 { @apply font-bold text-2xl md:text-3xl; }
h3 { @apply font-semibold text-xl md:text-2xl; }
/* … h4 to h6 */
}

The reference sites load a Google font with next/font and let it define --font-sans:

  1. Declare the font with variable: "--font-sans":

    import { Archivo } from "next/font/google";
    export const archivo = Archivo({
    subsets: ["latin"],
    style: ["normal", "italic"],
    display: "swap",
    variable: "--font-sans",
    });
  2. Put its class on <html>, in the locale layout:

    <html lang={locale} className={cn(archivo.variable, "antialiased")}>

Put the class on <html>, not on <body>. Tailwind sets the page font on html from --font-sans, so a value set lower in the tree has no effect.

You can set a token on any element. Everything inside follows. The reference sites make every button a pill while form fields keep a small radius:

[data-component="button"] {
--radius-base: 9999px;
}

Or give a checkout taller controls:

[data-component="checkout"] {
--control-height: 2.75rem;
}

Every component carries a data-component attribute that names it. Write these rules unlayered and without !important: package styles sit in Tailwind’s components layer, so a plain rule of yours always wins. For more than tokens, see Customize a component.

A surface swaps the surface roles as a unit. Set data-surface on an element and everything inside reads the colors of that plane. Surfaces nest:

<section data-surface="dark">
<Card>…</Card> {/* dark */}
<div data-surface="base">…</div> {/* light again */}
</section>

The default brand defines [data-surface="base"] and [data-surface="dark"]. A surface declares only the roles that differ, and inherits the rest. Override a brand role there only when your color disappears on that plane. For example, a near-black primary on a dark surface.

The content blocks’ hero sets data-surface="dark" when its text sits on a dark image. Define a dark surface in your brand, or that text keeps your light-plane colors.

Dark mode is not built in. To follow the operating system setting, add the dark values yourself:

@media (prefers-color-scheme: dark) {
:root:not([data-surface]) {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
/* … the rest of your dark surface */
}
}

Contrast depends on two colors, and both come from your brand. A component that passes with the default brand can fail with yours. @evolve-storefront/ui/contrast checks your stylesheet’s tokens without a browser.

  1. Add the test. It needs @evolve-storefront/commands and @evolve-framework/cli in your package.json:

    pnpm exec evolve add brand-contrast

    If your CLI does not find the command, name the package: pnpm exec evolve --package @evolve-storefront/commands add brand-contrast.

    It finds your stylesheet among src/app/global.css, src/app/globals.css, app/global.css, app/globals.css, src/styles/global.css and src/styles/globals.css. If it finds none or more than one, pass --stylesheet <path>. It keeps an existing test unless you pass --force.

  2. It writes brand-contrast.test.ts next to the stylesheet:

    import { fileURLToPath } from "node:url";
    import { checkBrandContrast } from "@evolve-storefront/ui/contrast";
    import { expect, it } from "vitest";
    // Contrast is a property of the brand's tokens, so a component's own tests cannot see it.
    it("the brand meets its contrast pairs", () => {
    const stylesheet = fileURLToPath(new URL("./global.css", import.meta.url));
    const failures = checkBrandContrast(stylesheet);
    expect(failures.map((failure) => failure.message)).toEqual([]);
    });
  3. Run it with your Vitest suite. Make sure Vitest’s include covers src/app. It does not run under Playwright, because ui ships TypeScript source.

A failure names the block, the pair and the ratio:

[data-surface="dark"]: --color-destructive-foreground on --color-destructive is 2.77:1, needs 4.5:1

The check reads @theme and :root as the root, then checks every other block that changes a color token (each surface, and your prefers-color-scheme block) with the values it inherits. These are the pairs (CONTRAST_PAIRS):

Pair Minimum
foreground and muted-foreground on background 4.5:1
muted-foreground on muted 4.5:1
primary, secondary, highlight, brand, destructive, each with its -foreground 4.5:1
destructive on background (error text) 4.5:1
input and ring on background 3:1

Check muted-foreground on muted first. Badges and table headers put muted text on a muted fill, and it is the pair that fails most often.

  • Do not redefine Tailwind’s container scale. Dialog sizes itself with max-w-lg, which reads --container-lg. If your theme repurposes --container-lg as a page width, dialogs become as wide as the page. Use --container-max for the page.
  • Do not set height on controls. Set --control-height. A component may switch to min-height later, and your height would then be wrong.
  • --color-accent is gone. It was split into highlight (hover plane) and brand (identity). A leftover --color-accent does nothing.