Skip to content
AstroCraft Docs
On this theme

Colors & Theming

Grafio uses Tailwind CSS v4, which means configuration lives in CSS rather than a JavaScript file. There is no tailwind.config.js anywhere in the project. Colour is organised in three layers, and understanding them is the difference between rebranding in one edit and hunting through components.

The three layers

Layer 1 — palette aliases. In src/styles/tailwind-theme.css, an @theme block names your brand on top of Tailwind’s own scale. Grafio is monochrome, so both ramps point at neutral:

@theme {
  --color-primary-50: var(--color-neutral-50);
  --color-primary-100: var(--color-neutral-100);
  /* … through 950 */

  --color-base-50: var(--color-neutral-50);
  --color-base-100: var(--color-neutral-100);
  /* … through 950 */
}

These give you text-primary-700, from-base-300 and so on — fixed values that do not respond to the theme.

Layer 2 — semantic runtime variables. In src/styles/global.css, a @layer base block declares the same names twice, once for :root and once for .dark. These are what flip:

:root {
  --background: var(--color-base-50);
  --foreground: var(--color-base-900);
  --primary: var(--color-primary-900);
  --muted: var(--color-base-100);
  --border: var(--color-base-300);
}

.dark {
  --background: var(--color-base-950);
  --foreground: var(--color-base-100);
  --primary: var(--color-primary-100);
  --muted: var(--color-base-900);
  --border: var(--color-base-800);
}

Layer 3 — the bridge. Back in tailwind-theme.css, an @theme inline block turns those runtime variables into utilities:

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-muted: var(--muted);
  --color-border: var(--border);
}

The inline keyword is required. Without it Tailwind resolves the variable to its value at build time and the utility stops responding to the theme class.

So text-foreground resolves through --color-foreground to --foreground to whichever of :root or .dark is active. That chain is the whole system.

The complete token set

Twenty-three semantic colour pairs plus a radius token:

  • Surfacesbackground, foreground, card, card-foreground, muted, muted-foreground, accent, accent-foreground
  • Brandprimary, primary-foreground, secondary, secondary-foreground
  • Statusinfo, success, warning, error, each with a -foreground partner
  • Chromeborder, input, outline
  • Shape--radius, default 0.5rem

The status colours are the one place the theme reaches directly at Tailwind’s built-in ramps (sky, green, amber, red) rather than through a palette alias, and three of the four are identical in light and dark. Only --error differs, because red at the same lightness reads differently on a near-black canvas.

--radius drives seven derived tokens through calc():

--radius-xs:  calc(var(--radius) - 0.375rem);   /* 0.125rem */
--radius-sm:  calc(var(--radius) - 0.25rem);    /* 0.25rem  */
--radius-md:  calc(var(--radius) - 0.125rem);   /* 0.375rem */
--radius-lg:  var(--radius);                    /* 0.5rem   */
--radius-xl:  calc(var(--radius) + 0.25rem);    /* 0.75rem  */
--radius-2xl: calc(var(--radius) + 0.5rem);     /* 1rem     */
--radius-3xl: calc(var(--radius) + 1rem);       /* 1.5rem   */

Changing that one value rescales every rounded corner on the site proportionally.

Watch the two “primary”s

--color-primary (semantic, flips with the theme) and --color-primary-500 (palette alias, fixed) coexist. So bg-primary and bg-primary-500 are different things, and the difference is visible only in the other theme. When in doubt, reach for the semantic one — that is what keeps dark mode free.

The rule for markup

Never write a raw Tailwind colour in a component. bg-violet-700 and text-zinc-300 bypass the whole system: they will not flip in dark mode and they will not follow a rebrand. Use bg-primary, text-foreground, border-border, bg-muted, or a palette alias like text-base-700 dark:text-base-300.

There is exactly one sanctioned exception, and it is narrow: a third party’s brand colour. Sections/About/Experience.astro paints each company’s logo tile with that company’s hex, applied inline. There is no dark-mode Airbnb red to flip to, and tokenising it would let a rebrand silently recolour somebody else’s mark. The test is ownership, not convenience — if you could restyle it, it is a token.

Decorative artwork does not qualify. The 404 illustration was moved onto currentColor with fill-background and fill-foreground precisely because it is the theme’s own, and its stock palette had left the paper layer light-mode-white in dark mode.

Dark mode

Declared once, class-based:

@variant dark (&:where(.dark, .dark *));

Paired with html { @apply bg-background text-foreground scheme-light dark:scheme-dark; }, so native UI — scrollbars, form controls, date pickers — matches the theme too.

Pair light and dark on the element so they cannot drift (text-base-700 dark:text-base-300), or let a semantic token do it for you (text-foreground already flips).

The pre-paint script

The theme class is set by an inline script in BaseHead before the browser paints, which is what stops the flash of the wrong theme:

function savedTheme() {
  try {
    return localStorage.getItem("colorTheme");
  } catch {
    return null;
  }
}

function initTheme() {
  const saved = savedTheme();
  const dark = saved
    ? saved === "dark"
    : defaultTheme === "system"
      ? window.matchMedia("(prefers-color-scheme: dark)").matches
      : defaultTheme === "dark";
  document.documentElement.classList.toggle("dark", dark);
}

Precedence is: a saved visitor choice wins; otherwise siteSettings.defaultTheme decides; and only "system" consults the device.

Two details matter if you touch this. It must stay an inline script — moving it into a bundled <script> reintroduces the flash. And it sets two classes on <html>, dark and js, both re-applied on astro:after-swap, because the view-transition router replaces the root element’s attributes with the incoming document’s. Losing the js class silently breaks the paused-entrance mechanism in <Reveal trigger="view">.

The ThemeToggle primitive writes localStorage["colorTheme"] with "dark" or "light". There is no third “system” state in the toggle — clicking always pins a value.

Rebranding

To change the brand colour, repoint the eleven --color-primary-* lines in src/styles/tailwind-theme.css:

@theme {
  --color-primary-50: var(--color-amber-50);
  --color-primary-500: var(--color-amber-500);
  /* … all eleven steps */
}

Everything follows automatically: the semantic --primary in both themes, every bg-primary* and from-primary-* utility, the .main-text-gradient class, the focus ring (which uses outline-primary-500), and the 404 illustration, which paints with currentColor under text-primary.

To change the neutral feel, repoint the eleven --color-base-* lines at zinc, stone, slate or a custom ramp. That drives the background, foreground, cards, muted surfaces, borders, inputs and outlines.

To retune the light/dark mapping without touching either ramp, edit the two blocks in global.css. Want a softer light canvas? --background: var(--color-base-100). Keep both blocks in step — that symmetry is the reason layer 2 exists.

To change corner rounding site-wide, edit the single --radius value.

To change the default theme for first-time visitors, set defaultTheme in src/config/siteSettings.json.ts. Test in a fresh browser profile — your own saved pick will otherwise mask the change.

Verifying

Run pnpm dev and open /examples/ui. It renders all 46 primitives in every variant, with a theme toggle in its header. A missing token shows up instantly as an un-themed element, and it is far faster than clicking through real pages.

Check both themes. A token that resolves correctly in dark and wrongly in light is the most common way a palette edit goes half-finished.

NEXT STEPTypography