Skip to content
Docs · 11 pages

Docs / Style

Theming

The Pro sections read shadcn's variable names with the base tokens as fallback. Paste a shadcn or tweakcn theme, set one --ma-brand (and optionally a neutral and an accent), or write the tokens yourself. The tone, density, align, columns and media props set the variants, and a painted tone re-declares the card, border, field and focus colours inside the section.

12 min readThis page as MarkdownSource on GitHub

Every Pro section reads the same variables shadcn’s components read, with the library’s base tokens as fallback:

background: var(--card, var(--ma-panel));
color: var(--card-foreground, currentColor);
border-radius: var(--ma-radius-3xl, 1.25rem);
padding: calc(4rem * var(--ma-density, 1)) 1.5rem;

So a page that defines nothing renders as before, and a page with a theme recolours all of them without editing one. The names are the API: renaming a token is a breaking change.

Three ways to apply a theme

1. Paste a shadcn or tweakcn theme. Whatever npx shadcn init or tweakcn wrote into your :root and .dark is read as is. Nothing to map. The theme fixture page is tested with tweakcn’s Amethyst Haze and shadcn’s neutral pasted over it, light and dark, in three engines.

2. Set one colour. Import the contract and change the brand:

@import '@moonarc/core/theme.css';

:root {
  --ma-brand: oklch(0.543 0.097 235);
}

theme.css derives everything else from it with oklch(from …), in CSS, at runtime:

  • the brand scale, --ma-brand-50 … 950; --primary is the 600 step in light and the 400 step in dark, and the focus ring follows it
  • --ma-neutral, a grey tinted with the primary’s hue (31.8° towards green, at low chroma), and its scale --ma-neutral-50 … 950, which the page, the text, the muted surfaces, the borders and the fields point at
  • --ma-accent, a second colour 143.5° away from the primary, softer and lighter, for highlights and fills
  • the four tone grounds and five chart colours

A static value is declared first for each, so a browser without relative colour (before Chrome 122 · Firefox 128 · Safari 18) shows the default palette instead of an invalid value; the theme builder copies every value precomputed for your colours under @supports not.

The neutral and the accent follow the brand until you set them yourself:

:root {
  --ma-brand: oklch(0.666 0.157 58.3);
  --ma-neutral: oklch(0.3 0.012 250);
  --ma-accent: oklch(0.74 0.07 180);
}

Their scales and everything that reads them follow the new value. A grey primary gives grey neutrals: the tint scales with the primary’s chroma.

Give --primary a colour or a --ma-brand-* step. The neutral, the accent and the tones are derived from it, so --primary: var(--foreground), var(--ma-neutral-900) or var(--ma-tone-ink) is a loop. A browser drops every colour in a loop and every colour read from one: the page loses its ground, its text and its primary. For a near-black primary, write the colour: --primary: oklch(0.22 0.01 250). Setting --ma-neutral as well ends the first two loops. It does not end one through the accent or a tone: var(--ma-tone-ink) still loops in dark, where the ink tone is derived from the primary.

3. Write the tokens yourself. Any subset; the rest falls back.

The builder at /theme does 2 with a colour picker, an optional neutral and accent, a radius, a density, a font pairing and a motion preset, previews real sections on all four tones, prints the contrast of every pair it changes, and hands you the block. The registry serves the default theme as a registry:theme item:

npx shadcn@latest add https://moonarc.dev/r/theme.json

It merges the seeds and the roles into your :root and .dark and adds theme.css, which derives the rest, so a later --ma-brand still recolours the scales. The CLI needs the components.json from Setup; its tailwind.css names the stylesheet the variables go into.

The default palette

The default theme comes from five colours. Each lands on a token:

Colour Hex Token
Cerulean #2978a0 --ma-brand; --primary is its 600 step, #24749c
Jet #253031 --ma-neutral, and the ground of tone="ink" in light
Sand #bcab79 --ma-accent
Pale Sky #b8ddf9 the ground of tone="muted" in light, derived as #b6def7
Slate #315659 none. As a field’s edge on the dark page it reads 2.40 : 1, under the 3 : 1 a field needs

Each derived value sits within 0.02 ΔEok of its colour (about one just-noticeable difference), and CI fails if one drifts further.

The tokens

shadcn’s, read by the sections (light values shown; theme.css has the dark ones):

Token Default Read by
--background / --foreground --ma-neutral-50 / --ma-neutral-900 tone="surface"
--card / --card-foreground white / --ma-neutral-900 cards, panels, table heads, the terminal window
--popover / --popover-foreground white / --ma-neutral-900 the MegaMenu panels (through Popover)
--primary / --primary-foreground --ma-brand-600 / --ma-neutral-50 buttons, chips, the ring
--secondary / --secondary-foreground --ma-neutral-100 / --ma-neutral-900 reserved
--muted / --muted-foreground --ma-neutral-100 / --ma-neutral-600 tone="muted" on a page without theme.css (the fallback)
--accent / --accent-foreground --ma-neutral-100 / --ma-neutral-900 hover backgrounds, icon wells, cover placeholders
--destructive / --destructive-foreground oklch(0.55 0.19 25) / --ma-neutral-50 an invalid field
--border, --input, --ring --ma-neutral-200, --ma-neutral-500, --ma-brand-600 hairlines, fields, focus
--chart-1 … --chart-5 derived from the brand Sparkline, BarGrow, the StatsRow bars
--radius 0.625rem every corner, through the scale below

shadcn’s --accent is a neutral hover surface. The palette’s second colour is --ma-accent, below.

Ours, above them:

Token Default What it does
--ma-brand oklch(0.543 0.097 235) the seed of the scales, the primary, the ring and the charts
--ma-brand-50 … --ma-brand-950 static, then derived lightness fixed per step, chroma tapered at the ends
--ma-neutral derived from the primary the tinted grey; set it to choose your own
--ma-neutral-50 … --ma-neutral-950 derived from --ma-neutral lightness fixed per step, chroma capped at 0.02
--ma-accent derived from the primary the second colour, as a fill; set it to choose your own
--ma-accent-text / --ma-accent-foreground derived the accent dark enough (light) or light enough (dark) to read as text on the page; text on an accent fill
--ma-success, --ma-warning, --ma-info (+ -foreground) fixed hues status colours; --ma-info is the primary
--ma-tone-muted, --ma-tone-ink, --ma-tone-brand derived the grounds of the three painted tones
--ma-tone-ink-foreground, --ma-tone-brand-foreground --ma-neutral-100, --ma-neutral-50 the text on ink and on brand
--ma-tone-glow-1 … 3 derived the colours of the Aurora on the CTA and the hero
--ma-card-shadow two soft shadows (light), a top-edge highlight (dark) a card’s elevation
--ma-radius-sm … --ma-radius-4xl --radius × 0.6 … 2.6 the radius scale, with shadcn’s multipliers. Tailwind’s own --radius-*, which every Tailwind project already defines, is left alone
--ma-density 1 the section padding multiplier; density="compact" is 0.8, roomy 1.25
--ma-font-display, --ma-font-body inherit headlines, section text
--ma-shadow-1 … --ma-shadow-3 three elevations lifted panels
--ma-sec-floor, --ma-dim 0.75 the faintest secondary text is drawn
--ma-sec-floor-brand 0.9 the same floor on a brand ground

Secondary text inside a section is the section’s own colour at reduced opacity (copy at 0.75 to 0.85, kickers and labels at 0.6 to 0.7), so it follows every tone without a token per tone. With theme.css each level is drawn at no less than --ma-sec-floor: 0.75 on the page, because 0.7 reads 4.29 : 1 on a theme whose own text and ground are 10 : 1, and 0.75 reads 4.91 : 1. tone="muted" raises the floor to 0.8 and tone="brand" to 0.9: on a colour, hierarchy comes from size and weight. CtaSection and HeroField paint brand when no tone is set, and take the same 0.9 from --ma-sec-floor-brand. The primitives read --ma-dim, which theme.css sets to the same 0.75.

Status colours

--ma-success, --ma-warning, --ma-info and shadcn’s --destructive each work as text on the page (at least 4.5 : 1) and as a fill under their -foreground. A tinted background is mixed where it is used, so there is no fourth token to keep in step:

.notice {
  color: var(--ma-success);
  background: color-mix(in oklab, var(--ma-success) 14%, transparent);
}

Warning is orange (hue 55 in light, 65 in dark) so it never reads as the accent. Where the engine has contrast-color() (Chrome 147 · Firefox 146 · Safari 26), every -foreground is black or white, whichever reads better on your colour.

Tailwind utilities for the tokens

theme.css is plain CSS. With Tailwind v4, a second, optional file maps the contract onto utilities the shadcn way. It uses @theme inline, so bg-background carries var(--background) itself and switches with the theme at runtime:

@import 'tailwindcss';
@import '@moonarc/core/theme.css';
@import '@moonarc/core/styles/theme.tailwind.css';

It gives bg-background, text-foreground, bg-primary, border-border, bg-brand-500, bg-ink, text-success, bg-warning, bg-info, and replaces Tailwind’s fixed rounded-* scale with the contract’s, so rounded-lg is --radius. The accent and the neutral scale have no utility names, because shadcn’s accent and Tailwind’s neutral already exist: write bg-(--ma-accent) or text-(--ma-neutral-600). Without Tailwind, never import it.

Dark mode

Light is the base. System dark applies unless the page has chosen light; <html data-theme="dark"> or <html class="dark"> wins in both directions. This is the three-state rule the base tokens follow, and what ThemeScript and ThemeToggle write. Every rule in theme.css is :where(), with zero specificity: your :root { … } or a pasted .dark { … } beats it whatever the import order.

A subtree can carry a theme of its own: <section data-ma-theme style="--ma-brand: oklch(0.6 0.2 30)"> re-derives every token inside it, and data-ma-theme="light" or "dark" pins its scheme. The builder’s preview is one such scope.

Variant props

Five props, on the sections that have a use for them; each writes a data-* attribute the section’s own CSS answers, so a prop and an attribute set by your script are the same thing:

Prop Attribute Values On
tone data-ma-tone surface · muted · ink · brand every section: the ground and everything on it; unset paints nothing, except the CTA and the HeroField panel (they default to brand), NavBar (a translucent card) and NewsletterSection (a card-coloured panel)
density data-density compact · cozy · roomy every section: block padding, or the cell padding of a grid
align data-align start · center sections with a heading
columns data-columns 2 · 3 · 4 BlogGrid, FeatureAtlas, IntegrationGrid, LogoWall, TeamGrid, TestimonialCards
media data-media right · left · none HeroEditorial, HeroTerminal, FeatureRows (alternating by default)

The tone’s attribute carries the library’s prefix because theme.css selects on it, and a global selector needs a name no other library uses.

The four tones are built to be told apart at a glance, in light and dark. surface is --background, with the decoration at a trace. muted is --ma-tone-muted, a band tinted with the primary in light and a lifted teal-grey panel in dark, with a hairline. ink is --ma-tone-ink, a deep panel in both schemes: the neutral itself in light, a deep version of the primary in dark. brand is --ma-tone-brand, the primary’s colour at lightness 0.46 in both schemes, with the decoration at full strength. A primary that is already a deep colour (a black one, as in shadcn’s own themes) is used as it is. A script proves every pair of grounds is at least 0.1 ΔEok apart for any brand colour with chroma from 0.097 to 0.28, and a browser test measures it again on every section as rendered.

A painted tone is also a theme scope. With theme.css, a section with data-ma-tone re-declares, for everything inside it:

Inside muted ink brand
--card white at 60 % (light), the text at 6 % (dark) the text at 6 % the text at 10 %
--border a deeper shade of the band (light), the text at 10 % (dark) the text at 10 % the text at 18 %
--input the text at 65 % the text at 65 % the text at 65 %
--ring the text the text the text
--card-foreground, --accent unchanged the text, and the text at 14 % the text, and the text at 14 %
--ma-card-shadow one soft shadow (light), a highlight (dark) a highlight at 6 % a highlight at 12 %
--ma-sec-floor, --ma-dim 0.8 0.75 0.9

So cards, hairlines, fields and focus rings follow the tone without the section writing them, and a field’s edge and a focus ring hold 3 : 1 on the ground. Set any of the --ma-tone-* tokens, or re-declare these names inside [data-ma-tone='…'], to change a tone. A page without theme.css keeps the tones’ grounds (each section paints its own with fallbacks) and its cards in the page’s card colour.

On a painted tone the primary action inverts (the tone’s foreground as the button, its ground as the text), so a button is never the colour of the panel it sits on. Every section page has a variants panel that writes these attributes on the running demo; the URL carries the choice.

Slots and class names for overrides

Every section accepts named slots for its parts (eyebrow, headline, copy, actions, media, aside, whichever it has), and a slot wins over the matching prop:

<CtaSection headline="unused when the slot is filled" primary={{ href: '/', label: 'Go' }}>
  <Fragment slot="headline">Ready <em>when</em> you are.</Fragment>
  <a slot="actions" href="/start/" class="my-button">Start</a>
</CtaSection>

A slotted headline is not split or shone by the section’s own text effect; it is your markup. The BEM class names (.ma-cta, .ma-cta__headline, …) are the override surface: stable, listed on each section’s page, and set at zero specificity, so one class of yours wins.

How contrast is checked

Two scripts run in CI. The first generates every static value from one table and proves, for every hue at chroma up to 0.28: text on the primary, the ring and the primary on the page, the neutral roles (text, muted text, a field’s edge at 3 : 1), the accent as text, the status colours both ways, the chart colours (at least 3 : 1, and adjacent series apart as seen and under a deuteranopia simulation), every pair of tone grounds, and the text on each tone and on a card inside it at the tone’s floor. It checks that each static value equals its derived line for the default brand, and that the default theme stays within 0.02 ΔEok of the palette. The second checks every pair a section paints, per tone, in both default themes. The contract’s own pairs all pass.

A pasted theme brings its own pairs, and some of them fail: tweakcn’s Amethyst Haze puts white text on its lavender primary at 3.64 : 1 in light. The theme builder prints the contrast of every pair it changes for the colours you pick.