Skip to main content

Theming

Reshape every Maquina component by declaring token values — radius, focus rings, elevation, weight and control marks — instead of writing override CSS.

Reshape every component by declaring token values, not override CSS.

Colors have always been CSS variables. As of 0.6.0 so are shape, focus rings, elevation and weight — which is the whole of what used to require override CSS.

The contract: a theme changes values, not selectors. If a theme needs a selector, either you are changing one component’s shape on purpose, or the token layer is missing a token — open an issue.

Upgrading from 0.5.1? Read Upgrading first — it leads with a one-line fix every existing app needs.


Role Tokens

Tokens are named for the role a value plays, not for a size, so controls and surfaces can be shaped independently.

Token Default Applies to
--control-radius 0.375rem Buttons, inputs, selects, textareas, badges, menu items, pagination links, calendar days, sidebar items
--surface-radius 0.5rem Cards, alerts, popovers, toasts, tables, stats, empty, calendar, drawer, the sidebar inset
--mark-radius 4px The checkbox box
--pill-radius calc(infinity * 1px) Radio, switch track
--focus-ring-width 3px Every focus ring
--focus-ring-offset 0px Every focus ring
--focus-ring-style solid Every focus ring
--focus-ring-color var(--ring) Every focus ring; invalid fields override it with the destructive tint
--elevation-control shadow-xs Inputs, selects, textareas, checkbox, radio
--elevation-raised shadow-sm Cards, stats cards, floating sidebar, every filled button
--elevation-overlay shadow-md Dropdown and combobox popovers, the date-picker popover, toasts, the drawer panel
--elevation-none none Ghost and link buttons, the inset sidebar
--label-weight 500 Labels, buttons
--value-weight 700 Stat values
--control-fill transparent Field background; re-set under .dark

Try it on any demo on this site. Every preview panel now carries a shape button next to the dark-mode toggle. It cycles defaultbrutalsoft, which are nothing but different values for the tokens above — no component selector is involved. Flip it on any component page to watch the token layer move.


Flat Theme in Six Lines

:root {
  --elevation-control: none;
  --elevation-raised: none;
  --elevation-overlay: none;
  --elevation-none: none;
  --control-radius: 0.25rem;
  --surface-radius: 0.25rem;
}

Every shadow in the library disappears and every box takes a 4px corner. The checkbox and the switch keep their own roles, which is the point of separating them.

Brutalist Theme in Twelve Lines

:root {
  --control-radius: 0;
  --surface-radius: 0;
  --mark-radius: 0;
  --pill-radius: 0;
  --focus-ring-width: 4px;
  --focus-ring-offset: 3px;
  --focus-ring-color: var(--foreground);
  --elevation-control: none;
  --elevation-raised: none;
  --elevation-overlay: none;
  --label-weight: 700;
  --value-weight: 900;
}

Square everything, thicken the ring and push it off the edge, drop every shadow, and make labels and values shout. No component selector anywhere.


Where the Declarations Go

Put them in a plain, unlayered :root block in your theme.css — that is what the installer generates, and unlayered CSS wins over the engine’s @theme defaults whatever the import order.

Do not wrap them in @theme: that emits into @layer theme alongside the engine’s own defaults, where source order becomes the only tie-breaker. Do not rename them into Tailwind’s namespaces (--radius-*, --shadow-*) either — a @theme { --radius-*: initial } in an app would wipe them.

/* app/assets/tailwind/theme.css */
:root {
  --surface-radius: 1rem;
}

Recoloring Control Marks

The checkbox tick, the checkbox dash, the radio dot, the switch thumb and the select chevron are whole SVG data URIs rather than a color token, and that is forced by CSS, not a choice: var() cannot be interpolated into url(), a data URI is a separate SVG document so currentColor never resolves inside it, and mask-image would mask the whole element — box, border and shadow — along with the glyph. So each mark is exposed as its own property.

Token Mark
--checkbox-mark-image Checkbox tick
--checkbox-indeterminate-image Checkbox dash
--radio-mark-image Radio dot
--switch-thumb-image Switch thumb
--select-chevron-image Select chevron

They theme like every other token: set one in :root (or in any theme block) and every control picks it up. The engine keeps its own artwork in the use-site fallback rather than declaring it on the control, precisely so that a global declaration wins.

:root {
  --checkbox-mark-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M8 2l1.8 4.2L14 8l-4.2 1.8L8 14l-1.8-4.2L2 8l4.2-1.8z'/%3e%3c/svg%3e");
}

Per-instance opt-in

A light --primary makes the default white ink measure about 1.15:1 against the checked fill. If that is a one-off rather than a theme-wide decision, one attribute fixes it with no CSS at all:

<%= f.check_box :terms, data: { component: "checkbox", mark: "dark" } %>

data-mark="dark" works on the checkbox, radio, switch and select; data-mark="light" is also available on the select. Both are declared on the control, so an explicit per-instance opt-in beats a global default — which is the right way round.

The select chevron carries a different default per color scheme, because gray-500 alone is low-contrast on a dark field. That default is inherited rather than declared on the control, so one :root line still retints it in both schemes. If you want a different ink per scheme, say so explicitly:

:root { --select-chevron-image: url("…dark ink…"); }
.dark { --select-chevron-image: url("…light ink…"); }

Dark Mode

Dark-mode differences are token values too, so you rarely need a .dark twin of a component rule. Set the token inside your own .dark block:

.dark {
  --focus-ring-color: color-mix(in oklch, var(--ring) 70%, transparent);
}

--control-fill is the one to know about: the engine re-declares it under .dark on the fields themselves, so overriding the dark field background needs a selector that reaches the control.

.dark [data-component="input"],
.dark [data-component="textarea"],
.dark [data-component="select"] {
  --control-fill: oklch(0.2 0.03 260);
}

Pinning One Component

Every radius and elevation site also reads a component-level property that falls back to the role token, so you can pin one component without redefining a role. Role tokens are the public API; these exist for the one-off.

:root {
  --card-radius: 0.75rem;   /* cards only; everything else stays 0.5rem */
  --toast-shadow: none;     /* toasts only */
}
Property Falls back to
--button-radius, --input-radius, --textarea-radius, --select-radius, --badge-radius, --pagination-radius, --toggle-group-radius, --date-picker-radius, --menu-button-radius, --sidebar-item-radius, --calendar-cell-radius, --combobox-item-radius, --dropdown-menu-item-radius, --toast-action-radius, --toast-close-radius, --drawer-close-radius --control-radius
--card-radius, --alert-radius, --table-radius, --stats-radius, --empty-radius, --fieldset-radius, --calendar-radius, --combobox-radius, --dropdown-menu-radius, --menu-button-content-radius, --date-picker-popover-radius, --sidebar-radius, --inset-radius, --avatar-radius, --toast-radius --surface-radius
--checkbox-radius --mark-radius
--radio-radius, --switch-radius --pill-radius
--card-shadow, --stats-shadow --elevation-raised
--combobox-shadow, --dropdown-menu-shadow, --menu-button-shadow, --date-picker-popover-shadow, --toast-shadow, --toast-hover-shadow, --drawer-shadow --elevation-overlay

Auditing an Existing Theme

maquina:doctor scans an app’s CSS, views and JavaScript and prints every place that restates something the token layer now owns, plus the one pattern that breaks outright. It never edits anything.

bin/rails maquina:doctor

See Upgrading for what changed in 0.6.0 and how to keep the 0.5.1 look.


Next Steps