来源：https://ui.docs.xihanfun.com/en/guide/theme

# Design tokens and theming

`@xihan-ui/tokens` provides two things: **design tokens** (one artifact of CSS custom properties) and the **theme runtime** (which resolves user preferences into attributes on the root element). It sits on the first level of the dependency graph and depends on no other package.

## The two token layers

```
primitive  ──►  semantic  ──►  component-private slots
the palette     roles with      override points of
itself          meaning         a single component
```

**primitive**: raw values independent of context. They never change in any scope and are declared once.

```css
--xh-color-brand-500: oklch(0.632 0.209 277);
--xh-color-neutral-950: oklch(0.145 0.005 258);
--xh-space-4: 16px;
--xh-radius-seed: 8px;
```

Colors use `oklch` instead of `hex` / `hsl`: different hues at the same lightness look equally bright, so dark inversion and contrast adjustments need no color-by-color hand tuning.

**semantic**: roles with meaning, pointing at primitives. Skins may only consume this layer.

```css
--xh-bg-canvas: var(--xh-color-neutral-0);
--xh-bg-surface: var(--xh-color-neutral-0);
--xh-bg-brand: var(--xh-color-brand-600);
--xh-fg-default: var(--xh-color-neutral-900);
--xh-fg-muted: var(--xh-color-neutral-600);
--xh-border-subtle: color-mix(in oklab, var(--xh-ink) 4.7%, transparent);
--xh-control-h-md: 32px;
--xh-shape-control: var(--xh-radius-md);
--xh-elevation-floating: var(--xh-shadow-md);
--xh-motion-duration-enter: var(--xh-duration-normal);
--xh-layer-modal: var(--xh-z-modal);
--xh-overlay-max-w: 20rem;
```

The semantic layer is grouped by role: `bg-*` backgrounds, `fg-*` foregrounds, `border-*` strokes, `control-*` control sizes, `shape-*` shapes, `ring-*` focus rings, `elevation-*` elevation, `motion-*` durations and easings, `layer-*` stacking, `overlay-*` overlay sizes and `text-*` typography.

The token sources are JSON in DTCG format (`packages/design/tokens/tokens/`), and the artifacts are generated by a build script; `tokens.css` / `tokens.json` / `src/generated/tokens.ts` are all committed. CI regenerates and compares them, and the build fails when sources and artifacts drift apart.

## Shape scale

Corner radii have one semantic scale; components never write their own radius values.

| Token | Value | Use |
| --- | ---: | --- |
| `--xh-shape-inset` | 2px | Inner layers embedded in a control: checkbox boxes, candidate rows of in-page lists, clear buttons inside fields, color swatches |
| `--xh-shape-control` | 2px | The control itself: Button, Input, Select Trigger, Toggle, kbd, tooltip, the track of the segmented radio group; square Tags, status tags and Alert bars |
| `--xh-shape-surface` | 4px | Static containers that form a surface: Card, Panel, list containers, the track of Tabs segment, the outlined squares of a pagination |
| `--xh-shape-overlay` | 4px | Overlays taken out of the document flow: Popover, Menu, Dialog, the card preset of Notification, the two page-facing corners of a Drawer (the two edge-hugging corners take no radius, the toast preset takes control); references surface, so changing the surface radius moves overlays along |
| `--xh-shape-circle` | 50% | Perfect circles: avatars, round icon buttons, radio indicators |
| `--xh-shape-pill` | 9999px | Pills: status chips such as Badge (Tags and result marks are square and take control), plus one-dimensional objects such as tracks, indicator bars, handles and scrollbar thumbs |

The shape tokens reference the raw scale `--xh-radius-sm / md / lg` (2 / 4 / 8px): inset and control take sm, surface takes md, and overlay references surface. Ordinary buttons, fields, cards and overlays do not use pill; Tags and result marks are square. List rows inside anchored overlays and the list rows of Command and Transfer are full-width rows without radius; an inner radius never exceeds the outer radius minus the padding; joined controls drop the radius on the touching sides. Light, dark and compact density do not change the shape identity. A square box takes circle and never fakes a circle with pill.

## Press feedback

Discrete action controls (Button, Toggle, icon buttons, pagination buttons, toolbar buttons) share one press timeline:

| Phase | Duration | Easing | Result |
| --- | ---: | --- | --- |
| Press | `--xh-motion-duration-press` (150ms) | `--xh-motion-ease-press` | Background enters active; scale goes from 1 to `--xh-motion-scale-press` (1 by default, only the face changes) |
| Release | `--xh-motion-duration-release` (200ms) | `--xh-motion-ease-release` | Scale returns to 1; background, stroke and text color return to hover / rest over `--xh-motion-duration-micro` |

By default only the face changes and nothing scales; for a press bounce, set `--xh-motion-scale-press` back to 0.97 on the root, with no change to the timeline.

Collection items such as Menu Item, Listbox Item, Tree Node and Table Row, and disclosure triggers such as Accordion / Collapsible, follow the same rhythm (150ms to the active face on press, back to hover / rest over micro on release), but only switch the surface: they never scale the whole row, and zero feedback is not allowed. With reduced motion, `--xh-motion-scale-press` becomes 1 and both durations become 1ms, while the color feedback stays.

## Scrolling inside components

Scrollbars come in only two kinds, fixed by the identity of the scrolling surface rather than decided by each component:

| Kind | Applies to | Tokens |
| --- | --- | --- |
| Custom-drawn bar (wired through the Scrollbar component) | content / list / column under the positioner of the Overlay family; small fixed-height lists (Listbox content, Transfer list, time columns, Cascader column) | `type` defaults to `scroll-hover`; thickness `--xh-scrollbar-thickness-sm` (4px) in overlays and `--xh-scrollbar-thickness-md` (6px) in pages; `--xh-scrollbar-track-bg: transparent` on the shell |
| Native thin bar | In-page structural containers (Table, Tree, Transfer panels, Virtualizer viewport, Dialog / Drawer / FloatingPanel body, Layout sider / content, SideNav popout, Log / MessageFeed viewports, the calendar year grid, Typography `pre`) and scroll containers you build yourself | The reset layer `:where([data-scope][data-part], [data-xh-scroll])` sets `scrollbar-width: thin` + `scrollbar-color: var(--xh-fg-scrollbar-thumb) var(--xh-bg-scrollbar-track)` for all of them |

The thumb has three color steps: `--xh-fg-scrollbar-thumb` / `-hover` / `-active` are 15% / 25% / 35% of the foreground color, shared by both kinds. Add `data-xh-scroll` to your own scroll container to get the same thin bar.

Boundary behavior goes by identity too: `overscroll-behavior: contain` only applies to overlay scrolling surfaces, modal bodies and bottom-pinned viewports, while in-page structural containers stay `auto`; `scrollbar-gutter: stable` only applies to containers whose content height changes dynamically (Log, MessageFeed, Dialog / Drawer body) and is guarded by `:not([data-xh-scrollbar])`; edge fading is only provided by the fade variant of ScrollArea and by Marquee. The page scrollbars of the documentation site use the same `type` as component scrollbars, without extra overrides.

## Typography roles

The `text-*` typography tokens are picked by role. Hierarchy is expressed by size, weight, line height and spacing together, never by color alone:

| Role | Size / weight / color | Spacing to neighbors |
| --- | --- | --- |
| Field labels (single fields and compound single fields such as Slider, Rating, Signature, Color*) | `--xh-text-label-size` 14 / `--xh-text-label-weight` 400 / `--xh-fg-muted` | `--xh-space-2` to the control when stacked; in horizontal Form layout the label column takes 5 / 24 of the row, with `--xh-space-4` to the control |
| Group titles (RadioGroup, CheckboxGroup, Listbox, Tree, TagGroup, Descriptions) | 14 / `--xh-text-label-weight` 400 / `--xh-fg-muted` | `--xh-space-2` to the group |
| Field descriptions / helpers (Field, Fieldset) | `--xh-text-caption-size` 12 / `--xh-fg-subtle` / `--xh-leading-normal`; no extra color change when disabled | Right under the control; the helper row is at least `--xh-space-5` high |
| Other descriptions (descriptions inside Card, Alert, Dialog and the like) | `--xh-text-secondary-size` 13 / `--xh-fg-muted` / `--xh-leading-normal`; step descriptions of Steps take the secondary caption 12 / `--xh-fg-subtle` | — |
| Error messages | 12 / `--xh-fg-danger` | Share the helper row under the control with the description |
| Titles of overlays and small surfaces (Popover, HoverCard, Popconfirm, FloatingPanel, Approval, EmptyState, Citation) | 14 / `--xh-font-weight-medium` | — |
| Section and panel titles (Card, Descriptions, Alert, Notification, Steps; Dialog, Drawer, Tour) | heading-3 (`--xh-text-heading-3-*`): 16 / 500 | — |
| Page titles (PageHeader) | heading-2 (`--xh-text-heading-2-*`): 20 / 500 | — |
| Large numbers (Statistic, Timer, NumberAnimation) | sm / md / lg take heading-2 / heading-1 / display (`--xh-text-display-size`): 20 / 24 / 28 | — |
| Secondary marks (counts, shortcuts, timestamps, ordinals) | `--xh-text-caption-size` 12 | — |

The required asterisk and error messages are common-layer rules: the asterisk `--xh-glyph-mark-required` sits before the label text, takes `--xh-text-caption-size` + `--xh-fg-danger` and is `--xh-space-1` away from the text; fields with their own label do not draw a separate one. Field labels and field descriptions do not change color when disabled, other labels take `--xh-fg-subtle` when disabled, and single-line labels use `--xh-leading-none`. Icons inside controls follow the size step with `--xh-glyph-size-sm / md / lg` (16 / 18 / 20); `--xh-glyph-size-text` (1em, following the text) is only for purely inline text components such as Tag, Kbd, Breadcrumb, Typography and Highlight.

Components do not set a font; `font-family` is inherited from the host as usual. The library's default sans-serif stack lives in `--xh-font-family-sans` (system UI fonts first, then PingFang, Hiragino Sans GB, Microsoft YaHei and Noto Sans SC for Chinese) and only reaches the page root when you import the optional entry:

```ts
import "@xihan-ui/styles/global.css";
```

This entry is not part of the main `@xihan-ui/styles` entry. The rule sits in the lowest `xihan.reset` layer with its selector wrapped in `:where()`, so any font the host sets still wins. Code and tabular figures use `--xh-font-family-mono`.

## The eight-axis visual environment runtime

`VisualEnvironmentController` is the single entry for the state and DOM projection of mode, brand, density, dir, contrast, motion, transparency and material. One `setPreference` synchronously commits all eight axes of a scope:

```ts
import { setMotionOverride } from "@xihan-ui/motion";
import {
  brandId,
  createMotionOverrideSink,
  createVisualEnvironmentController,
} from "@xihan-ui/tokens/runtime";

const visual = createVisualEnvironmentController({
  root: document.documentElement,
  storageKey: "app-visual-environment",
  onStorageError: detail => reportStorageFailure(detail),
  motionSink: createMotionOverrideSink(setMotionOverride),
  initial: {
    mode: "system",
    brand: brandId("xihan"),
    density: "comfortable",
    dir: "ltr",
    contrast: "system",
    motion: "system",
    transparency: "system",
    material: "standard",
  },
});

visual.setPreference({ mode: "dark", density: "compact" });
visual.getState();
visual.subscribe(state => console.log(state));
visual.dispose();
```

Enabling `storageKey` requires `onStorageError` as well. Parse failures, private browsing or an exceeded quota are reported explicitly; the controller keeps maintaining its in-memory state but never pretends a failed persistence succeeded.

The `motionSink` of the root controller is explicit dependency injection: it syncs the resolved motion into Presence, smooth scrolling and JS animations. Only the `documentElement` root scope may pass it; local controllers only change their own DOM scope and cannot implicitly pollute the global JS override.

The eight-axis state is projected into the full attribute surface that portals can bridge:

```html
<html data-theme="dark" data-brand="xihan" data-density="compact" data-contrast="more" data-motion="reduce" data-transparency="reduce" data-material="liquid" dir="rtl">
```

## Preference, state and system axes

| | `VisualEnvironmentPreference` | `VisualEnvironmentState` |
| --- | --- | --- |
| Meaning | The intent submitted by the user or the server | The fully settled facts |
| `undefined` | Clears this layer's override and inherits from the parent controller | Does not occur; all eight axes are non-empty |
| `'system'` | Only valid for mode / contrast / motion / transparency | Does not occur; resolved to a concrete value |

brand, density, dir and material have no platform media query, so they do not accept a fake `system`. The other four read `prefers-color-scheme`, `prefers-contrast`, `prefers-reduced-motion` and `prefers-reduced-transparency`. SSR environments without `window` / `matchMedia` use light, default contrast, default motion and default transparency, without guessing the client's preferences.

The baseline of the eight axes is light, xihan, comfortable, ltr, default, default, default, standard. `contrast='default'` is the official value for explicitly restoring normal contrast; the old `base` is no longer accepted.

| DOM axis | Description |
| --- | --- |
| `data-theme` | Light and dark each have a full set of semantic values; `system` follows the system without JS, see below |
| `data-brand` | Switches the brand color: presets work directly, custom brands are registered with `registerBrand` first |
| `data-density='compact'` | Tightens control heights, padding and gaps without shrinking font sizes or glyphs |
| `dir` | Logical properties drive the LTR / RTL layout; quantities that only know physical directions (translate, gradient angles, the left and right sides of clip-path) are multiplied by `--xh-direction-sign`, which inherits from the nearest `dir` (1 for ltr, -1 for rtl), so a subtree set back to `ltr` flips back; physical coordinates such as `transform-origin` take `--xh-direction-start` / `--xh-direction-end` (the horizontal position of the inline start / end edge: 0% / 100% for ltr, the other way round for rtl) |
| `data-contrast` | `more` strengthens boundaries; `default` explicitly returns to the normal level |
| `data-motion` | `reduce` triggers local CSS motion reduction |
| `data-transparency` | `reduce` switches translucent materials opened by a theme back to solid recipes |
| `data-material` | `liquid` turns the navigation-layer parts floating above content (float buttons, media controls, the sticky top bar) into liquid surfaces; `standard` keeps the original material; the nearest ancestor wins |

### Follow the system color scheme without JS

The controller's `mode: "system"` resolves light or dark only once the script runs. When a page just wants to follow the system without loading a script for it, or needs to be right before the script arrives, write `data-theme="system"` in the HTML:

```html
<html data-theme="system">
```

On a light system it takes the same values as `light`. When the system switches to dark, the dark token values, `color-scheme` and the few skin rules that only hold in dark (tones, solid buttons and so on) switch over through `prefers-color-scheme`, without any script, and follow every later system switch. `system` is only a value written in the DOM; the controller still projects only `light` / `dark`, so a controller created later on the same root replaces the attribute with the resolved value.

## Preset brands

`@xihan-ui/tokens` ships a few named brands. The list is `BRAND_PRESETS` (such as `blue`, `green`, `orange`, `pink`) and the type is `BrandPreset`; `BRAND_PRESET_COLORS` gives each preset a representative color (its brand 600), for drawing swatches and preset lists. Each preset is a `[data-brand='<id>']` value block in `tokens.css` that only swaps the brand primitives, without `registerBrand`:

```ts
visual.setPreference({ brand: brandId("green") });
```

Each preset takes the hue of the same name from the base palette, and every step is derived from the relative luminance of the same step of the default brand; `indigo` is the former default brand (hue 258), kept as a preset after the default moved to an indigo at hue 277. Contrasts that involve the brand (text on solid backgrounds, brand text color, the brand tint with text on it, the focus ring) therefore meet the same targets as the default brand; light, dark and both high-contrast levels are checked step by step by a gate. A value block of the same name injected by `registerBrand` is not layered and wins over the preset.

`data-brand` only switches the brand when it sits on the same element as `data-theme`: semantic tokens are declared at the theme boundary, so swapping brand primitives on a child element leaves the semantic tokens at the values resolved outside. The visual environment controller always writes both attributes on its root; when writing them by hand, put `data-theme` on the same element as well.

## Parent scopes, portals and cleanup

Local controllers inherit explicitly through `parent`. When the parent state changes, a child controller only recomputes the axes without a local preference; `setPreference({ motion: undefined })` removes this layer's override and restores inheritance immediately. dispose unsubscribes from the parent scope and the system media queries, and restores the eight attributes of the root element exactly as they were before it took over.

```ts
const page = createVisualEnvironmentController({
  root: document.documentElement,
  initial: { mode: "dark", motion: "system" },
});
const editor = createVisualEnvironmentController({
  root: document.querySelector("#editor")!,
  parent: page,
  initial: { density: "compact", transparency: "reduce" },
});
```

React's `XhConfigProvider` and Vue's `provideXhConfig` accept controller options with an explicit root through `config.visualEnvironment`; nested providers pick up the parent controller automatically. The Web Components `<xh-config>` is a local scope by itself and can set `mode`, `brand`, `density`, `direction`, `contrast`, `motion`, `transparency` and `material` directly. Physical portals are still bridged with the resolved eight axes by Core's VisualBridge; the adapters keep no visual state of their own.

## The five-axis legacy view

`createThemeController`, `resolveTheme` and `applyThemeAttrs` still exist, but they are only an explicit five-axis view of the new controller: they have no resolver, media query or persistence of their own. New code should use the eight-axis controller; a standalone `applyThemeAttrs` only writes five axes, while the controller always maintains the full eight-axis DOM contract.

The `color-scheme` declaration is written into the dark value block, so native controls inside nested dark regions follow along. SSR should output the eight resolved attributes on first paint to avoid a flash before the client takes over.

## Material recipes

There are five token recipes, each providing the same nine tokens: `bg`, `backdrop`, `border`, `highlight`, `shadow`, `separator`, `fg`, `fg-muted` and `focus-surface`; M5 liquid has five extra tokens of its own. By default every recipe is a solid surface; translucency (a translucent background plus a backdrop filter) may only be turned on by a theme for transient overlays. There is no glass material and no compatibility alias. Two more stacking levels, raised and floating, are made of elevation tokens: they have no `--xh-material-*` tokens and are always written as a solid stroke + solid background + the matching elevation.

| Id | Tokens | Use | Optics |
| --- | --- | --- | --- |
| M0 solid | `--xh-material-solid-*` | Default for static content surfaces: border-default stroke + surface background + no shadow; fields at rest are stroked the same way on `--xh-bg-field` (white by default) | Solid background, no highlight, no shadow |
| M1 soft | `--xh-material-soft-*` | Secondary surfaces with a soft tint; not for Card or fields. Button and Tag are flat surfaces and do not take its top light or shadow | Solid background, a subtle top light and a two-step contact shadow, no backdrop blur |
| M2 frosted | `--xh-material-frosted-*` | Anchored transient overlays such as short lists, menus, tooltips and bubbles; anchored panels with grids or several columns use solid + border-default + `--xh-elevation-floating` instead | Solid surface background, no backdrop blur, 1px border-default stroke, a medium (md) shadow, no top light |
| M4 elevated | `--xh-material-elevated-*` | Modal and strong-feedback surfaces (sheets) such as Dialog, Drawer, Command, Tour and Notification; always with a 1px stroke | Solid surface background, no backdrop blur, a large (lg) shadow |
| M5 liquid | `--xh-material-liquid-*` | Only under `data-material="liquid"`: the navigation layer floating above content — float buttons (FloatButton, BackTop, the scroll-to-bottom of MessageFeed / Log), media controls (Carousel controls and pagination bar, the ImageViewer control layer), the sticky Layout header | Readable floor of 0.48 (light) / 0.61 (dark) opacity, 8px blur, 140% saturation, an ink hairline plus a 1px rim light, edge refraction in Chromium |
| raised (stacking level) | solid stroke + solid background + `--xh-elevation-raised` | Card and parts that can be lifted or dragged (the slider of Tabs segment, switch thumbs and the like), registered per part; the stroke must be there and the shadow is only a bonus; hover lifting only when interactive | Solid background; `--xh-elevation-raised` takes the small (sm) shadow |
| floating (stacking level) | solid background + `--xh-border-default` + `--xh-elevation-floating` | Anchored panels with grids or several columns: NavigationMenu content, Date / Time / DateRange / TimeRange picker content | Solid background, no translucency, a medium (md) shadow |

| Suffix | Use |
| --- | --- |
| `bg` / `backdrop` | Background color / backdrop filter |
| `border` / `highlight` / `shadow` | Outer boundary, top highlight and shadow |
| `separator` | Inner divider lines |
| `fg` / `fg-muted` | Body and secondary text, always opaque |
| `focus-surface` | A solid isolation layer laid inside the focus ring on keyboard focus |

M2 frosted has a single implementation in the library: the material family recipe `@xihan-ui/styles/material.css`. Components project `data-xh-material="frosted"` onto a part; the recipe paints the stroke, background, foreground and shadow of the content surface of anchored overlays, while the backdrop filter and the 1px top light are empty by default and left to themes that want translucency. The surfaces of float buttons belong to Action Control, and the recipe gives them the interaction steps (tint one and two on hover and press, `focus-surface` on keyboard focus). A custom overlay surface joins by writing the same attribute and overrides with `--xh-frosted-bg`, `--xh-frosted-border`, `--xh-frosted-fg`, `--xh-frosted-shadow`, `--xh-frosted-backdrop` and `--xh-frosted-highlight`.

Card has only three forms: `outline` (default, solid stroke + surface background + `--xh-elevation-raised`, the small shadow), `subtle` (`--xh-bg-subtle` + a transparent placeholder border + no shadow) and `ghost` (no border, background or shadow; only dividers are allowed). Boundaries are drawn only by the stroke; neither shadows nor tints serve as boundaries.

The environment axes degrade in place under the same token names: high contrast strengthens boundaries; reduced transparency and print force solid backgrounds and turn off backdrop filters (even when a theme turned translucency on); forced colors express everything through `Canvas` / `CanvasText`. All four levels are verified in both the light and the dark theme against black, white, mid gray, the page and the brand background: body text at least 4.5:1 and the focus ring at least 3:1 against its isolation layer.

Theme and contrast can be declared on different levels: each level takes the nearest `data-theme` and `data-contrast`. A child theme does not cancel the high contrast of an ancestor, `data-contrast="default"` restores the normal level locally, and sibling regions do not affect each other.

### Where frosted overlay surfaces are used

- The name stays frosted, but the default value is a solid popup layer: it does not sample the content behind it and has no top boundary light; a 1px stroke carries the visible boundary, and layering never relies on the shadow alone.
- Only for anchored transient overlays; never for body text, the main part of forms, Card, Table, Notification or the main reading surface of Dialog.
- When a theme wants frosted translucency, give `--xh-material-frosted-bg` a translucent background, give backdrop a blur (no more than 16px, saturate no more than 1.08) and give highlight an inner top light of at most 1px. Once translucency is on, do not put `backdrop-filter` on the page root, large scrolling areas or overlapping layers, do not animate the blur radius, and pair the focus ring with the solid isolation surface of `focus-surface`.

### The compact recipe (Tooltip)

Tooltip is not inverted: it uses the same overlay face as Popover, taking its background, text and edge from the frosted material (M2) and combining three compact recipes. Only when `tone` is set does it switch to a solid tone fill, and then a 20% mix of the on-color carries the 1px visible boundary (a neutral stroke is invisible on a colored fill):

| Recipe | Normal value |
| --- | --- |
| `--xh-material-frosted-compact-alpha` | Opaque (`--xh-alpha-opaque`) |
| `--xh-material-frosted-compact-backdrop` | none, no blur |
| `--xh-material-frosted-compact-shadow` | The medium (md) shadow (`--xh-shadow-md` in light, the same three dark layers in dark) |

High contrast, forced colors and print remove the shadow; reduced transparency keeps a small shadow to show the overlay's position. Components consume the optical parameters only through these three recipes and never pick alpha or blur primitives directly.

### Liquid material

`data-material` is the application-level material axis, `standard` by default. With `liquid`, the navigation-layer parts floating above content turn into liquid surfaces. It can sit on any ancestor and the nearest one wins; the portal visual bridge carries it to the instance shell; under standard the same parts keep their original material. When you use the visual environment controller, set it through its `material` preference (the controller always maintains this attribute on the root).

The current liquid parts: the FloatButton trigger and its expanded actions, BackTop, the scroll-to-bottom of MessageFeed / Log, the page and play buttons and the pagination bar of Carousel (resting on a liquid capsule), the toolbar, counter, page and close buttons of ImageViewer, and the header of a fixed Layout. Transient overlays, modals, Notification, Card, Table, forms and body containers never use liquid, which the `check-material-scope` gate enforces.

```html
<html data-material="liquid">
  <!-- For images, videos, canvases and other areas whose colors cannot be read, the author declares how light the layer below is -->
  <section data-xh-backdrop="dark" data-xh-backdrop-busy>…</section>
</html>
```

The author only writes this one attribute. Once the components are mounted, the liquid surface of `@xihan-ui/core/visual-environment` takes over; the parts of one document share one set of listeners, with nothing extra to install or call:

- The tint follows the computed color of the layer under the part and the author's declaration: a light layer below gets the light tint, a dark one the dark tint, and the part becomes a black-ink or white-ink zone accordingly (relative luminance 0.179 ± 0.04 with hysteresis, so content scrolling across the boundary does not flicker).
- When the layer below is uniform and its tint is known, the surface switches to the clear level (0.24 light / 0.34 dark); when the layer below has text, gradients, images, videos, canvases or cannot be read, it stays at the readable floor, and labels are at least 4.5:1 on any layer below. Content set `inert` on the page (absent from the hit stack) is treated as unreadable too.
- The layer below is re-read on scroll, window resize, image load, the end of transitions or animations, and when state-machine-driven translations settle (carousel paging, image viewer inertia).
- With a fine pointer, the 1px rim light turns toward the pointer; with a coarse pointer, no pointer or reduced motion it stays fixed at the top left (top right in RTL).
- In Chromium the layer below is refracted within 18px of the edge; in other engines, above 640 × 120, or with more than 3 in one viewport, it only blurs without refraction.

| Extra token | Use |
| --- | --- |
| `--xh-material-liquid-tint` | Tint of the clear level |
| `--xh-material-liquid-alpha-clear` / `-alpha-floor` | Opacity of the clear level and of the readable floor |
| `--xh-material-liquid-rim-far` | The weak bright edge on the side away from the light |
| `--xh-material-liquid-bezel` | Width of the refraction band |

Server rendering and the state before mounting output the static form: the tint follows the theme and the opacity takes the readable floor. With reduced transparency and in print the opacity is 1 without backdrop filters or refraction; high contrast uses opacity 1, and forced colors express it through system colors.

## Using the tokens directly

```ts
import type { TokenName } from "@xihan-ui/tokens";
// Typed token names on the TypeScript side
import { tokens } from "@xihan-ui/tokens";

// The machine-readable artifact: for Figma variables, a Tailwind theme or design annotations
import tokens from "@xihan-ui/tokens/tokens.json" with { type: "json" };
```

## Related

- [Installation](../installation): the import order of tokens and skins
- [Accessibility and keyboard](./a11y): the focus ring contract
- Skin layering, in Chinese: [皮肤与样式分层](/guide/styling)
