---
title: "Theming and panel chrome"
description: "Customize panel color, density, logos, and application-owned CSS."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Theming and panel chrome

The token system, accent ramp, dark mode, Tailwind preset, and panel chrome follow
[protocol v1 §1](/protocol/v1) (the `adonia` envelope). Everything lives in
`packages/ui`: tokens in `src/theme/tokens.ts` and `styles.css`, the ramp in
`src/theme/accent_ramp.ts`, chrome components in `src/components/chrome/`, and
the Tailwind preset in `src/tailwind/preset.ts` (published as
`@adonia/ui/tailwind`).

## Tokens

Every visual decision reads a `--adonia-*` custom property; no component
hard-codes a colour, radius, or spacing step. `TOKENS` in
`src/theme/tokens.ts` is the machine-readable manifest — name, family,
description, light default, optional dark override — and `styles.css` ships
the same defaults. `tests/theme.test.ts` fails if the two drift, so edit them
together.

| Family | Tokens |
| --- | --- |
| Colour | `surface`, `surface-muted`, `border`, `text`, `text-muted`, `danger`, `warning`, `success` |
| Accent | `accent` (alias of `accent-600`), `accent-50` … `accent-950` |
| Radius | `radius`, `radius-sm`, `radius-lg` (the last two derive from `radius`) |
| Font | `font-family`, `font-family-mono`, `font-size-xs` … `font-size-2xl` |
| Density | `density`, `space-1` … `space-6` (each `calc(var(--adonia-density) * n)`) |

Because the spacing scale is a multiple of one unit, a panel switches from
comfortable to compact by changing `--adonia-density` alone.

## Accent ramp

A panel declares one accent — `brand.accent` in the envelope, or
`theme.accent` client-side. `buildAccentRamp(accent)` expands it into the
eleven steps at runtime: pure, dependency-free, identical on server and
client, so `renderToString` emits the finished ramp and hydration never
flashes.

```ts
import { buildAccentRamp } from '@adonia/ui'

buildAccentRamp('violet')['600'] // 'oklch(54.1% 0.247 293)'
```

Accepted notations: a named accent (`'violet'`, the Tailwind families), hex,
`rgb()`, `oklch()`. Anything unparseable degrades to the default accent — a
typo must not blank an admin.

The ramp keeps the accent's **hue and chroma** but imposes a fixed,
monotonically decreasing **lightness** scale. That is deliberate: contrast
relationships between steps then hold no matter how light or dark the brand
colour is, so `accent-50` is always a tint you can put text on and
`accent-600` is always a solid surface that `accent-50` reads against.

## Dark mode

`theme.darkMode` picks a strategy:

| Value | Behaviour |
| --- | --- |
| `'system'` (default) | The OS preference, resolved by a `prefers-color-scheme` block in `styles.css`. No JavaScript. |
| `'class'` | The user chooses. `ThemeProvider` scopes the effective choice to the panel and its owned portal root; `useColorScheme()` persists it to `localStorage` under `adonia.color-scheme`. A leased `.dark` compatibility signal on `<html>` never removes a host-owned class. |
| `'off'` | Light only. |

Both dark blocks are scoped to the provider's
`[data-adonia-color-scheme="…"]` attribute. The manual block also keys on
`data-adonia-effective-scheme`, so a host app's own `.dark` class cannot
repaint a light panel.
Each `AdoniaProvider` owns a `[data-adonia-portal]` container with the same
theme variables, scheme, and direction as its panel; host Base UI portals are
not selected or restyled.

```tsx
import { useColorScheme } from '@adonia/ui'

function ThemeToggle() {
  const { scheme, toggle } = useColorScheme()
  return <button onClick={toggle}>{scheme === 'dark' ? 'Light' : 'Dark'}</button>
}
```

`useColorScheme` is SSR-safe: it reads through `useSyncExternalStore` with a
server snapshot, and the module never touches `window` at the top level.
Storage that throws (opaque origins, private mode) degrades to a
session-only choice.

## Panel chrome

`PanelShell` composes the chrome around a page from the §1 envelope:

```tsx
import { PanelShell } from '@adonia/ui'
import type { AdoniaEnvelope } from '@adonia/ui'

export default function PostsIndex(props: AdoniaEnvelope) {
  return (
<PanelShell envelope={props} breadcrumbs={[{ label: 'Posts' }]}>
  {/* page content */}
</PanelShell>
  )
}
```

- **`Sidebar`** — brand link to `panel.urls.dashboard`, a `navigation`
  landmark labelled "Main" holding `NavTree`, and the `sidebar.footer` slot.
- **`NavTree`** — renders `NavNode[]`: groups become labelled nested lists,
  items become links. The active item carries `aria-current="page"`.
- **`Topbar`** — `Breadcrumbs`, the `topbar.right` slot, then the user menu
  (a disclosure button with a `menu`, closed by Escape).
- **`Toaster`** — `adonia.flash.success` as `role="status"`,
  `adonia.flash.error` as `role="alert"`, each dismissible.
- A skip link precedes the sidebar and `<main>` is focusable, so keyboard
  users are not walked through the nav on every page.

The shell has no authorization logic. A resource the user may not reach is
**absent** from `panel.navigation` (protocol §2 rule 3), so nothing renders
for it — there is no "hidden" state to get wrong on the client. A
`protocolVersion` other than `1` throws, per protocol §1.

Slots are filled through the registry:

<!-- @sample-preamble
import type { ComponentType } from 'react'
declare const ThemeToggle: ComponentType
declare const StorageMeter: ComponentType
-->

```ts
import { adonia } from '@adonia/ui'

export default adonia({
  registry: (r) => {
r.slot('topbar.right', ThemeToggle)
r.slot('sidebar.footer', StorageMeter)
  },
})
```

## Tailwind preset

```ts
// tailwind.config.ts
import adonia from '@adonia/ui/tailwind'
export default { presets: [adonia] }
```

The preset maps tokens onto theme keys and nothing else — no content globs,
no plugins, no `darkMode` setting. Adonia's own files are scanned through the
mandatory `@source '../../node_modules/@adonia/ui';` directive, so utilities land as
`bg-adonia-surface`,
`text-adonia-text-muted`, `bg-adonia-accent-600`, `rounded-adonia`,
`p-adonia-4`, `text-adonia-sm`, `font-adonia`. Every value is a `var()`
reference, so changing the accent or the colour scheme repaints without
regenerating a single class.

## Out of scope here

Action modals, widgets, lenses, and tenant switching are Phase 3+; the
`topbar.right` / `sidebar.footer` slots are where they will attach.

Source: https://adonia.pages.dev/theming/index.mdx
