
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](https://github.com/maquina-app/maquina_components/issues).

Upgrading from 0.5.1? Read [Upgrading](/documentation/components/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` |

<%= render Shared::ComponentDemo.new(component: "specimen", example: "shape", height: 520) %>

> **Try it on any demo on this site.** Every preview panel now carries a **shape** button next to the dark-mode toggle. It cycles `default` → `brutal` → `soft`, 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

```css
: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

```css
: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.

<%= render Shared::ComponentDemo.new(component: "specimen", example: "elevation", height: 560) %>

---

## 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.

```css
/* 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.

```css
: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:

```erb
<%%= 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:

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

<%= render Shared::ComponentDemo.new(component: "specimen", example: "marks", height: 420) %>

---

## 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:

```css
.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.

```css
.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.

```css
: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.

```bash
bin/rails maquina:doctor
```

See [Upgrading](/documentation/components/upgrading/) for what changed in 0.6.0 and how to keep the 0.5.1 look.

---

## Next Steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="/documentation/components/upgrading/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Upgrading to 0.6.0
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      The preflight shim fix, the scanner, and every breaking change.
    </p>
  </a>

  <a href="/documentation/components/form/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Form Components
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Where control radius, marks and focus rings show up first.
    </p>
  </a>
</div>
