Radius, border & shadow
Twenty-four tokens covering corner radius, border width, the focus ring
composite, and a six-step elevation set built from two derived tints. Every value here is a
literal in tokens.css and nowhere else.
Radius
Eight steps. --radius-none is a real token rather than a bare
0, and --radius-full is a large pixel value rather than
50% — a percentage radius on a non-square box gives you an ellipse, which is
almost never what a pill button wanted.
The boxes below are identical in every respect except the corner.
--radius-none
--radius-xs
--radius-sm
--radius-md
--radius-lg
--radius-xl
--radius-2xl
--radius-full
| Token | Value | Typical use |
|---|---|---|
--radius-none | | Squaring off something that inherited a radius — a code block flush inside an example. |
--radius-xs | | Hairline softening. Bars, ticks, tiny indicators. |
--radius-sm | | Inline code, badges, list items in a nav. |
--radius-md | | The default control radius. Inputs, selects, buttons. |
--radius-lg | | Cards, panels, dialogs. |
--radius-xl | | Large surfaces, kiosk-scale tiles. |
--radius-2xl | | Full-bleed hero and marketing blocks. |
--radius-full | | Pills, avatars, the theme switch in this page's masthead. |
Border width
Four widths, in px rather than rem. A border is a hairline, not
type: scaling it with the root font size makes a 1px rule land on a fractional device pixel
and go grey.
--border-width-0
--border-width-1
--border-width-2
--border-width-4
There is no --border-width-3. The numbers are the pixel values, not scale
positions, so the gap is information rather than an omission.
--border-width-0 exists for the same reason --space-0 does: so that
removing a border is still written as a token.
Border colour is not border width
Widths are --border-width-0 through --border-width-4. Colours are
--color-border,
--color-border-subtle and --color-border-strong. There is no
--border-color-* and there never will be.
The split is by what the value is, not by what it is used for. Everything that
resolves to a colour lives in the colour family and is documented on the
colour page, because colour tokens theme and lengths do not.
A border is the one place those two families meet in a single shorthand, which is exactly
why they are kept on separate pages: reading --border-width-1 solid
var(--color-border) should make it obvious that only the second half changes between
light and dark.
The focus ring
Three tokens compose one ring. base.css applies them as an
outline, not a box-shadow and not a border, so the ring never
changes layout and never gets clipped by a parent's overflow.
| Token | Value | Aliases |
|---|---|---|
--focus-ring-width | | var(--border-width-2) |
--focus-ring-offset | | var(--border-width-2) |
--focus-ring-color | | var(--color-focus), itself var(--color-brand) |
Press Tab to move into these. Clicking a button will not
show the ring — that is :focus-visible doing its job, not a bug.
CSS from base.css
:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}The colour is aliased twice on purpose:
--focus-ring-color → --color-focus →
--color-brand. Retune --hue-brand and the focus ring follows without
anyone touching this file. The middle link exists so a product that needs a focus colour
distinct from its brand — a high-contrast kiosk build, for instance — has one
place to break the chain, rather than having to override every ring at every call site.
Do not remove the ring and put nothing back. If a component needs a different focus
treatment, redefine --focus-ring-color or --focus-ring-width on
that component and let the same outline rule draw it. The offset is a real
token so the ring can sit clear of a filled control without a second element.
Shadow
Six elevations plus --shadow-inset, built from exactly two tint tokens.
Two tints, derived not authored
Every elevation is two layered shadows: a tight, low-offset
penumbra that reads as contact, and a broader, softer umbra that reads as
distance. That is why --shadow-md looks like an object above a surface rather
than a grey smudge behind it — one shadow alone cannot do both jobs at once.
| Tint | Resolved | Role | |
|---|---|---|---|
--shadow-penumbra |
|
Tight contact shadow. Lighter tint, small blur, no spread. | |
--shadow-umbra |
|
Broad ambient shadow. Denser tint, large blur, negative spread. |
Neither tint is hand-authored. Both are color-mix() results: a step from the
neutral ramp mixed against transparent to get the alpha, wrapped in
light-dark() so the dark theme can be much denser than the light one. The mix
result is still a <color>, which is all light-dark() will
accept.
--shadow-penumbra: light-dark(
color-mix(in oklab, var(--color-neutral-900) 6%, transparent),
color-mix(in oklab, var(--color-black) 30%, transparent)
);This is the point of the whole arrangement. These two were previously the only hand-authored
colours in tokens.css, which meant retuning --hue-neutral moved
every grey in the system while the shadows stayed on the old hue. Deriving them from the ramp
keeps a shadow the same colour family as the greys it is a shadow of, automatically.
The elevation set
| Token | Layers | Use |
|---|---|---|
--shadow-none | none | Flat. A token, so “no elevation” is still declarative. |
--shadow-xs | penumbra only | A pressed segment, a kbd key. Barely off the page. |
--shadow-sm | penumbra + umbra | Resting cards and list rows. |
--shadow-md | penumbra + umbra | Raised cards, hovered rows. |
--shadow-lg | penumbra + umbra | Dropdowns, popovers, sticky bars. |
--shadow-xl | penumbra + umbra | Modal dialogs. base.css puts this on dialog. |
--shadow-inset | inset umbra | Wells and sunken tracks. Reads as recessed, not raised. |
Light and dark, side by side
Shadows read completely differently across themes, and this is the section that needs the two-panel treatment more than any other on the site. In light mode the elevation ladder is obvious. In dark mode it is nearly invisible: a dark shadow on a dark surface has almost nothing to contrast against, so the steps collapse into each other.
--shadow-none--shadow-xs--shadow-sm--shadow-md--shadow-lg--shadow-xl--shadow-inset--shadow-none--shadow-xs--shadow-sm--shadow-md--shadow-lg--shadow-xl--shadow-insetThose two panels are one page in one theme. The resolved values printed under each name are read per panel at runtime, which is why the offsets match and the tints do not.
Pair a raised surface with a shadow
The semantic surface roles are deliberately asymmetric, and the shadow tokens are the other half of that decision. See the elevation-asymmetry note on theming & dark mode for the full reasoning.
| Theme | --color-surface | --color-surface-raised | What carries elevation |
|---|---|---|---|
| Гэрэлтэй | --color-white |
--color-white — identical |
The shadow, entirely. Colour contributes nothing. |
| Харанхуй | --color-neutral-900 |
--color-neutral-800 — one ramp step lighter |
Lightness, mostly. The shadow is nearly invisible. |
Pair a raised surface with a shadow token. Never rely on colour alone.
A card that sets only background-color: var(--color-surface-raised) is
completely invisible in light mode, because raised and base are the same white. A card that
sets only box-shadow: var(--shadow-md) all but disappears in dark mode. Set
both, every time, and each theme is carried by whichever half actually works there.
.card {
background-color: var(--color-surface-raised);
box-shadow: var(--shadow-md);
border-radius: var(--radius-lg);
}