Foundations
Colors
Primitive ramps, the --color-* semantic layer built on them, and a deprecated alias for every legacy name. Use the semantic layer. See docs/frontend/design.md.
1. Primitives
Greys.
Low numbers are backgrounds and borders, high numbers are text. All of them lean slightly yellow rather than pure grey — that warmth is what makes the page read as paper instead of as a white screen.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--white
|
hsl(0, 0%, 100%)
|
Ink for anything see-through — hover shading, drop shadows.
Looks too brown to use as a solid fill, deliberately: transparency washes most of the color out, and --neutral-900 faded comes back the cold grey we're avoiding.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--ink-overlay
|
hsl(44, 65%, 26%)
|
Page canvas.
Warmer than any grey above, which spread across a whole page read a little cold. Only --color-background should use it.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--paper
|
hsl(48, 31%, 84%)
|
Blue — the one accent: links, primary buttons, focus rings.
Leans slightly purple rather than sky, so it doesn't go cold next to the warm greys.
Red, green, amber — error, success, warning.
50 is a message background, 200 its border, 400 the bright alarm step for tiny attention dots, 500 a solid object (filled icon, destructive button), and 600/700 the text; only those last two are dark enough to read on white.
Brick red rather than fire-engine, so errors read serious, not alarming — 400 is the one deliberate exception, because an 8px dot has to shout.
Olive rather than mint, so success states sit with the warm greys.
Amber — warning states, plus the gold on star ratings.
Pink — data-chart bars only.
400 clears 3:1 on white as a non-text shape 600 is the selected bar, a clear step darker.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--pink-400
|
hsl(348, 95%, 65%)
|
|||
--pink-600
|
hsl(345, 62%, 42%)
|
Bars in the /stats daily charts.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--stat-yellow
|
hsl(45, 83%, 59%)
|
2. Semantic tokens
What component CSS should actually use: named for a job, each pointing at one primitive above. That single mapping is what a future dark mode would reroute.
Text
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-text
|
hsl(41, 14%, 21%)
via --neutral-800 |
|||
--color-text-heading
|
hsl(41, 14%, 21%)
via --neutral-800 |
same ink as body; size and weight carry the hierarchy | ||
--color-text-secondary
|
hsl(41, 12%, 36%)
via --neutral-600 |
|||
--color-text-muted
|
hsl(42, 11%, 42%)
via --neutral-500 |
only readable on white or --neutral-50 | ||
--color-text-inverse
|
hsl(0, 0%, 100%)
via --white |
The dark fill that --color-text-inverse sits on — tooltips, dark chips.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-surface-inverse
|
hsl(41, 14%, 21%)
via --neutral-800 |
Decorative tier: 2.5:1 on white, under WCAG 1.4.11's 3:1.
Inert chrome only — a chevron beside a label, a placeholder glyph. An icon that carries meaning on its own needs --color-text-muted or darker.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-icon-muted
|
hsl(43, 13%, 62%)
via --neutral-400 |
Surfaces
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-background
|
hsl(48, 31%, 84%)
via --paper |
page canvas — what body paints | ||
--color-surface
|
hsl(0, 0%, 100%)
via --white |
cards, inputs, dropdowns | ||
--color-surface-raised
|
hsl(0, 0%, 100%)
via --white |
A step lighter than -header: recessed panels and image wells should read as a shade off the surface around them, not as a filled block.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-surface-sunken
|
hsl(48, 38%, 97%)
via --neutral-50 |
|||
--color-surface-header
|
hsl(48, 32%, 94%)
via --neutral-100 |
Links
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-link
|
hsl(210, 84%, 31%)
via --blue-600 |
|||
--color-link-hover
|
hsl(211, 82%, 24%)
via --blue-700 |
|||
--color-link-visited
|
hsl(214, 79%, 19%)
via --blue-800 |
Environment badge & favicon — match the static/images/openlibrary-{development,testing}-*.png favicons.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--env-badge-development
|
hsl(136, 50%, 46%)
|
|||
--env-badge-testing
|
hsl(30, 53%, 50%)
|
Primary action
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-primary
|
hsl(210, 82%, 40%)
via --blue-500 |
|||
--color-primary-hover
|
hsl(210, 84%, 31%)
via --blue-600 |
|||
--color-primary-active
|
hsl(211, 82%, 24%)
via --blue-700 |
Edge of a raised primary control: a half-step below the fill, as --color-control-border is for the neutral one, so the shape reads without an outline.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-primary-border
|
color-mix(in srgb, var(--blue-500), var(--blue-600))
|
|||
--color-primary-subtle
|
hsl(218, 65%, 95%)
via --blue-50 |
|||
--color-on-primary
|
hsl(0, 0%, 100%)
via --white |
Destructive action fill (delete, remove). Pair with --color-on-primary.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-destructive
|
hsl(13, 55%, 42%)
via --red-500 |
Disabled
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-disabled-bg
|
hsl(45, 22%, 77%)
via --neutral-300 |
|||
--color-disabled-fg
|
hsl(42, 11%, 42%)
via --neutral-500 |
Hover for flat rows — menu items, list rows, popover items.
See-through, so it darkens whatever surface the row sits on rather than forcing one grey.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-hover-overlay
|
color-mix(in srgb, var(--ink-overlay) 8%, transparent)
|
Resting fill for raised neutral controls — the secondary button and its static twins. The first step off white on the warm ramp, so a control reads as a distinct surface on a white card without going grey; --color-control-hover is the next step down the same ramp.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-control-bg
|
hsl(48, 38%, 97%)
via --neutral-50 |
Edge of a raised neutral control.
A half-step: --color-border-subtle reads too faint now that the fill is no longer white, and --color-border-muted (the hover edge below) reads as an outline rather than an edge. The ramp is evenly spaced here, so this is one role wanting a half-step rather than a missing rung, and it's a mix like the selected-state tokens above.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-control-border
|
color-mix(in srgb, var(--neutral-200), var(--neutral-300))
|
Hover for raised controls.
One step down the ramp from --color-control-bg which is what makes the hover register now that the resting fill is already off white. Opaque — see --color-control-selected-surface.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-control-hover
|
hsl(48, 32%, 94%)
via --neutral-100 |
Inset track that a selected pill floats in — ol-segmented-control.
One step below --color-control-bg: enough for the pill to sit proud of it, while staying lighter than --color-border-subtle so the pill keeps a visible outline against the track.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-control-track
|
hsl(48, 32%, 94%)
via --neutral-100 |
Drag handle on a sheet or tray — see-through ink, like --color-hover-overlay.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-drag-handle
|
color-mix(in srgb, var(--ink-overlay) 20%, transparent)
|
Loading skeletons (components/skeleton.css); see-through, so they work on any surface.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-skeleton
|
color-mix(in srgb, var(--ink-overlay) 8%, transparent)
|
|||
--color-skeleton-highlight
|
color-mix(in srgb, var(--white) 40%, transparent)
|
Selected look for neutral controls.
Shared by ol-button[selected] and ol-toggle[variant="button"][checked] so the two can't drift apart.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-control-selected-bg
|
color-mix(in srgb, var(--blue-500) 8%, transparent)
|
|||
--color-control-selected-bg-hover
|
color-mix(in srgb, var(--blue-500) 12%, transparent)
|
|||
--color-control-selected-border
|
color-mix(in srgb, var(--blue-500) 35%, transparent)
|
|||
--color-control-selected-border-hover
|
color-mix(in srgb, var(--blue-500) 50%, transparent)
|
Solid versions of the tints above.
Always hand --control-surface one of these — alpha leaks into the top highlight and turns it into a dark line.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-control-selected-surface
|
color-mix(in srgb, var(--blue-500) 8%, var(--white))
|
|||
--color-control-selected-surface-hover
|
color-mix(in srgb, var(--blue-500) 12%, var(--white))
|
Borders & outline roles
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-border
|
hsl(42, 11%, 42%)
via --neutral-500 |
|||
--color-border-hover
|
hsl(41, 12%, 36%)
via --neutral-600 |
|||
--color-border-focused
|
hsl(210, 82%, 40%)
via --blue-500 |
|||
--color-border-pointed
|
hsl(210, 82%, 40%)
via --blue-500 |
ring on the item under the pointer, e.g. a hovered book cover | ||
| — |
--filter-pointed-dim
|
brightness(0.88)
|
artwork under the pointer dims so the ring reads as the active edge |
Edge of an image, e.g. a book cover: see-through ink, so a pale cover gets a line and a dark one keeps its own.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-border-media
|
color-mix(in srgb, var(--ink-overlay) 15%, transparent)
|
|||
--color-border-error
|
hsl(13, 55%, 42%)
via --red-500 |
|||
--color-border-muted
|
hsl(45, 22%, 77%)
via --neutral-300 |
dividers — decorative, 1.6:1 on white | ||
--color-border-subtle
|
hsl(47, 27%, 86%)
via --neutral-200 |
|||
--color-border-extra-subtle
|
hsl(48, 32%, 94%)
via --neutral-100 |
repeated row separators — decorative, 1.11:1 on white | ||
--color-focus-ring
|
hsl(210, 82%, 40%)
via --blue-500 |
Messages and notices. Info borrows the blue ramp rather than its own hue.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-info-fg
|
hsl(210, 84%, 31%)
via --blue-600 |
|||
--color-info-bg
|
hsl(218, 65%, 95%)
via --blue-50 |
|||
--color-info-bg-hover
|
hsl(217, 63%, 91%)
via --blue-100 |
info chip on a --color-primary-subtle hover | ||
--color-info-border
|
hsl(216, 55%, 83%)
via --blue-200 |
|||
--color-success-fg
|
hsl(105, 36%, 26%)
via --green-600 |
|||
--color-success-bg
|
hsl(98, 30%, 93%)
via --green-50 |
|||
--color-success-border
|
hsl(99, 26%, 79%)
via --green-200 |
|||
--color-error-fg
|
hsl(11, 58%, 35%)
via --red-600 |
|||
--color-error-bg
|
hsl(14, 50%, 95%)
via --red-50 |
|||
--color-error-border
|
hsl(14, 45%, 84%)
via --red-200 |
|||
--color-warning-fg
|
hsl(36, 75%, 32%)
via --amber-600 |
|||
--color-warning-bg
|
hsl(46, 80%, 95%)
via --amber-50 |
|||
--color-warning-border
|
hsl(47, 72%, 78%)
via --amber-200 |
Counterparts for status *shapes* — switches, bars, dots.
The -fg ramps are tuned for AA text and read muddy as a block of color. The neutral one is the same shape switched off: dark enough that a white knob still reads on it.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-success-object
|
hsl(130, 58%, 35%)
via --green-400 |
|||
--color-error-object
|
hsl(12, 90%, 50%)
via --red-400 |
|||
--color-warning-object
|
hsl(42, 74%, 50%)
via --amber-400 |
|||
--color-neutral-object
|
hsl(45, 22%, 77%)
via --neutral-300 |
Data lines — sparklines and small charts, one hue per series in this order. The 500 steps: dark enough that a 2px line reads on white, not so dark they turn to ink.
Bars in data charts (reading-log stats). Counts sit past the bar end in text ink.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-chart-bar
|
hsl(348, 95%, 65%)
via --pink-400 |
|||
--color-chart-bar-selected
|
hsl(345, 62%, 42%)
via --pink-600 |
Bars in the publishing-history chart on subject and publisher pages.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-publishing-history-bar
|
hsl(210, 82%, 40%)
via --blue-500 |
Default bar colour for the daily stats charts on /stats.
| Preview | Token | Value | Contrast | Notes |
|---|---|---|---|---|
--color-stats-chart-bar
|
hsl(45, 83%, 59%)
via --stat-yellow |
3. Which token do I use?
Pick by purpose, not by shade — full guidance in docs/frontend/design.md.
- New code uses a semantic token, never a primitive (
--neutral-500) or a rawhsl(). If none fits, add one here and name it for its job. - Touching a file on a legacy name? Migrate it. At zero consumers the alias gets deleted.
Most names say what they're for. The ones that don't:
| You are styling… | Use |
|---|---|
| Body text | --color-text |
| Muted metadata (bylines, counts, timestamps) | --color-text-muted — AA on white and --neutral-50 only, not on the page canvas |
| A default border | --color-border; dividers → --color-border-muted, hairlines → --color-border-subtle |
| An inert icon (chevron beside a label, placeholder glyph) | --color-icon-muted — decorative only, see its note above |
| Hovering a flat row (popover/menu/list item) | --color-hover-overlay — an overlay, so it works on any surface |
| Hovering a raised control | --color-control-hover — opaque, safe to feed --control-surface |
Deprecated aliases (68) — do not use in new code
Blues → blue ramp
| Legacy name | Now points at | |
|---|---|---|
--primary-blue |
→ |
--blue-500
|
--link-blue |
→ |
--blue-600
|
--mid-blue |
→ |
--blue-400
|
--dark-blue |
→ |
--blue-800
|
--blue-b0bed9 |
→ |
--blue-200
|
--grey-blue |
→ |
--blue-100
|
--button-hover-blue |
→ |
--blue-400
|
--dark-baby-blue |
→ |
--blue-200
|
--mid-baby-blue |
→ |
--blue-100
|
--blue-9fafd1 |
→ |
--blue-300
|
Greens → green ramp
| Legacy name | Now points at | |
|---|---|---|
--green |
→ |
--green-500
|
--mid-baby-green |
→ |
--green-100
|
--dark-green |
→ |
--green-600
|
--soft-green |
→ |
--green-200
|
--soft-green-two |
→ |
--green-300
|
--olive |
→ |
--green-600
|
--light-olive-2 |
→ |
--green-300
|
--lime-green |
→ |
--green-100
|
--check-green |
→ |
--green-500
|
Oranges → amber ramp
| Legacy name | Now points at | |
|---|---|---|
--orange |
→ |
--amber-400
|
--orange-three |
→ |
--amber-200
|
--orange-four |
→ |
--amber-200
|
--orange-five |
→ |
--amber-500
|
Reds → red ramp
| Legacy name | Now points at | |
|---|---|---|
--red |
→ |
--red-500
|
--dark-red |
→ |
--red-600
|
--red-two |
→ |
--red-500
|
--red-three |
→ |
--red-500
|
Pinks
| Legacy name | Now points at | |
|---|---|---|
--mid-baby-pink |
→ |
--red-100
|
Yellows & beiges → neutral / amber ramps
| Legacy name | Now points at | |
|---|---|---|
--dark-yellow |
→ |
--amber-200
|
--beige-three |
→ |
--neutral-300
|
--beige |
→ |
--neutral-200
|
--beige-pale |
→ |
--neutral-100
|
--brown |
→ |
--neutral-700
|
--light-yellow |
→ |
--amber-100
|
--lighter-yellow |
→ |
--amber-50
|
--light-beige |
→ |
--neutral-100
|
--cream |
→ |
--neutral-100
|
--off-white |
→ |
--neutral-50
|
--gold |
→ |
--amber-400
|
Greyscale → warm neutral ramp
| Legacy name | Now points at | |
|---|---|---|
--black |
→ |
--neutral-900
|
--dark-grey |
→ |
--neutral-800
|
--grey-464646 |
→ |
--neutral-700
|
--darker-grey |
→ |
--neutral-700
|
--grey-555 |
→ |
--neutral-700
|
--grey |
→ |
--neutral-600
|
--accessible-grey |
→ |
--neutral-500
|
--mid-grey |
→ |
--neutral-400
|
--light-mid-grey |
→ |
--neutral-400
|
--light-grey |
→ |
--neutral-300
|
--lighter-grey |
→ |
--neutral-200
|
--grey-e7e7e7 |
→ |
--neutral-200
|
--lightest-grey |
→ |
--neutral-100
|
--grey-f3f3f3 |
→ |
--neutral-100
|
--grey-fafafa |
→ |
--neutral-50
|
--gray-a19b9e |
→ |
--neutral-400
|
--gray-d1cfd0 |
→ |
--neutral-300
|
--grey-f4f4f4 |
→ |
--neutral-100
|
Header chrome — gradient flattens to paper tints
| Legacy name | Now points at | |
|---|---|---|
--header-bar-gradient-start |
→ |
--neutral-100
|
--header-bar-gradient-end |
→ |
--neutral-200
|
Status
| Legacy name | Now points at | |
|---|---|---|
--open-green |
→ |
--green-500
|
--closed-red |
→ |
--red-500
|
Kept literals — niche hues with no ramp home, plus alpha tokens, which var() indirection can't carry.
| Legacy name | Now points at | |
|---|---|---|
--teal |
→ |
hsl(184, 45%, 26%)
|
--magenta |
→ |
hsl(310, 45%, 45%)
|
--blue |
→ |
hsl(230, 45%, 45%)
|
--boxshadow-black |
→ |
hsla(44, 60%, 18%, 0.15)
|
--icon-link-grey |
→ |
hsla(0, 0%, 0%, 0.1)
|
--background-grey |
→ |
hsla(0, 0%, 78%, 0.23)
|
--account-icon-background |
→ |
hsla(37, 24%, 79%, 0.761)
|
Typography
Every text token in one place: the primitives (families, the size ramp, weights, line heights) first, then the roles that combine them. A role is one token per property, applied together: a consumer sets each property from its token rather than picking one and hard-coding the rest.
1. Primitives
Raw values. Rarely used directly in components; reference a role token.
Font families
Schibsted Grotesk and Literata are self-hosted webfonts (static/css/fonts/) that only render on pages whose bundle imports them; everywhere else the stacks fall through to the system fonts they always used. Schibsted covers Latin and Latin Extended only, so Greek, Cyrillic and the rest fall through to the system sans, with Noto Sans behind that.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Ag |
--font-family-sans
|
"Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
|
|
| Ag |
--font-family-serif
|
Literata, Georgia, "Palatino Linotype", "Book Antiqua", Palatino, serif
|
|
| Ag |
--font-family-mono
|
"Lucida Console", "Courier New", monospace
|
Font sizes
The size ramp, in the display / headline / title / label / body vocabulary. Elements and roles pick a step from here.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Ag |
--font-size-display-large
|
57px
|
|
| Ag |
--font-size-display-medium
|
40px
|
|
| Ag |
--font-size-display-small
|
36px
|
|
| Ag |
--font-size-headline-large
|
32px
|
|
| Ag |
--font-size-headline-medium
|
28px
|
|
| Ag |
--font-size-headline-small
|
24px
|
|
| Ag |
--font-size-title-large
|
22px
|
|
| Ag |
--font-size-title-medium
|
16px
|
|
| Ag |
--font-size-title-small
|
14px
|
|
| Ag |
--font-size-label-large
|
14px
|
|
| Ag |
--font-size-label-medium
|
12px
|
|
| Ag |
--font-size-label-small
|
11px
|
|
| Ag |
--font-size-body-large
|
16px
|
|
| Ag |
--font-size-body-medium
|
14px
|
|
| Ag |
--font-size-body-small
|
12px
|
Font weights
Four steps, matching the 400–700 range the variable webfonts cover. Off-scale values in legacy rules (650, 300) snap to the nearest step when migrated. Fall back to a primitive only where no role fits.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Ag |
--font-weight-regular
|
400
|
body copy, the default |
| Ag |
--font-weight-medium
|
500
|
quiet emphasis: overlines, large headings |
| Ag |
--font-weight-semibold
|
600
|
small headings and labels that must stand apart from copy |
| Ag |
--font-weight-bold
|
700
|
strong emphasis, table headers |
Line heights
Unitless, so leading scales with the font size. Smaller for impact (big headings) and scanning (dense UI text); larger for reading.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Ag
Ag |
--line-height-single
|
1
|
single-line UI (only when you clamp/won't wrap) |
| Ag
Ag |
--line-height-tight
|
1.2
|
big headings, compact UI |
| Ag
Ag |
--line-height-snug
|
1.35
|
headings, metadata, dense UI text |
| Ag
Ag |
--line-height-normal
|
1.5
|
body default (accessibility-friendly baseline) |
| Ag
Ag |
--line-height-relaxed
|
1.65
|
long descriptions / reading |
| Ag
Ag |
--line-height-loose
|
1.8
|
very long-form reading / large line lengths |
2. Roles
Text styles named for the job the text does. A role gains a token for a property once two or more consumers hand-code the same value for it, so some roles below carry a single property for now.
Heading
Section headings, h1–h3. Medium weight in body ink; the size ramp carries the hierarchy per element, so the role has no size token.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Book Details |
--font-family-heading
|
"Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
via --font-family-sans |
|
| Book Details |
--font-weight-heading
|
500
via --font-weight-medium |
|
| Ag
Ag |
--line-height-heading
|
1.35
via --line-height-snug |
also result and work titles that wrap |
Subheading
h4–h6. These sit at body sizes, so semibold is what separates them from copy; don't lighten them to match the heading role.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Edition Identifiers |
--font-family-subheading
|
"Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
via --font-family-sans |
|
| Edition Identifiers |
--font-weight-subheading
|
600
via --font-weight-semibold |
Overline
The small all-caps heading over a group: popover section headings ("Sort by"), search-modal result groups ("Top results"), design-page nav groups. Sits on --font-size-label-medium at a medium weight, and tracks slightly tight so the caps read as one word rather than a row of letters.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Sort by |
--font-size-overline
|
12px
via --font-size-label-medium |
|
| Sort by |
--font-weight-overline
|
500
via --font-weight-medium |
|
| Sort by |
--letter-spacing-overline
|
0.02em
|
|
| Sort by |
--text-transform-overline
|
uppercase
|
Families by role
Which stack each kind of text uses. These fold into full role blocks above as the roles gain a second property.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Ag |
--font-family-body
|
"Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
via --font-family-sans |
body text, paragraphs, lists |
| Ag |
--font-family-code
|
Menlo, "Lucida Console", "Courier New", monospace
|
code, pre, inline code |
| Ag |
--font-family-title
|
Literata, Georgia, "Palatino Linotype", "Book Antiqua", Palatino, serif
via --font-family-serif |
large text in banners and hero sections, book titles |
| Ag |
--font-family-subtitle
|
Literata, Georgia, "Palatino Linotype", "Book Antiqua", Palatino, serif
via --font-family-serif |
small text in banners and hero sections, book subtitles |
| Ag |
--font-family-quote
|
Literata, Georgia, "Palatino Linotype", "Book Antiqua", Palatino, serif
via --font-family-serif |
quotes |
| Ag |
--font-family-button
|
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"
|
buttons; still the system stack until ol-button is checked on webfont pages |
Line heights by role
Which leading each kind of text uses. These fold into full role blocks above as the roles gain a second property.
| Preview | Token | Value | Notes |
|---|---|---|---|
| Ag
Ag |
--line-height-display
|
1.2
via --line-height-tight |
hero sections, very large titles |
| Ag
Ag |
--line-height-title
|
1.35
via --line-height-snug |
result titles / work titles (often wrap) |
| Ag
Ag |
--line-height-body
|
1.5
via --line-height-normal |
paragraphs, default text |
| Ag
Ag |
--line-height-meta
|
1.35
via --line-height-snug |
bylines, counts, timestamps, secondary text (search cards) |
| Ag
Ag |
--line-height-blurb
|
1.35
via --line-height-snug |
short snippet, often in narrow containers (list item notes in grid) |
| Ag
Ag |
--line-height-list
|
1.35
via --line-height-snug |
multi-line lists of links/subjects (subject pages) |
| Ag
Ag |
--line-height-table
|
1.35
via --line-height-snug |
edition detail grids / data tables |
| Ag
Ag |
--line-height-code
|
1.5
via --line-height-normal |
code, pre |
| Ag
Ag |
--line-height-control
|
1.2
via --line-height-tight |
buttons, form labels, nav items (wrapping allowed) |
| Ag
Ag |
--line-height-chip
|
1.1
|
badges/tags/pills |
Spacing
Spacing Primitives
A curated set of spacing values based on a 4px grid. Rarely used directly in components — use semantic tokens instead.
Keep these columns aligned (lookup table).
prettier-ignore
| Preview | Token | Value | Notes |
|---|---|---|---|
--spacing-0
|
0
|
||
--spacing-3xs
|
2px
|
2px – hairline gaps (px intentional) | |
--spacing-2xs
|
0.25rem
|
4px | |
--spacing-xs
|
0.375rem
|
6px | |
--spacing-sm
|
0.5rem
|
8px | |
--spacing-md
|
0.75rem
|
12px | |
--spacing-lg
|
1rem
|
16px | |
--spacing-xl
|
1.25rem
|
20px | |
--spacing-2xl
|
1.5rem
|
24px | |
--spacing-3xl
|
2rem
|
32px | |
--spacing-4xl
|
2.5rem
|
40px | |
--spacing-5xl
|
3rem
|
48px | |
--spacing-6xl
|
4rem
|
64px | |
--spacing-7xl
|
5rem
|
80px |
Spacing Semantic Tokens
Reference these throughout the app for consistent spacing.
Categories:
- Inline: horizontal space between sibling elements
- Stack: vertical space between sibling elements
- Inset: internal padding of containers
- Layout: large-scale page/section spacing
Keep these columns aligned (lookup table).
prettier-ignore
Inline: horizontal gaps between sibling elements
┌───┐ ↔ ┌───┐ ↔ ┌───┐ │ A │ │ B │ │ C │ └───┘ └───┘ └───┘
Use for: row gaps, icon-to-label, toolbar items, pagination buttons, breadcrumb separators
| Preview | Token | Value | Notes |
|---|---|---|---|
--spacing-inline-xs
|
2px
via --spacing-3xs |
2px – hairline glyph-to-text (e.g. pagination arrows) | |
--spacing-inline-sm
|
0.25rem
via --spacing-2xs |
4px – related controls (e.g. search bar elements) | |
--spacing-inline-md
|
0.5rem
via --spacing-sm |
8px – default inline gap (e.g. toolbar actions) | |
--spacing-inline-lg
|
0.75rem
via --spacing-md |
12px – between cards (e.g. carousel book covers) | |
--spacing-inline-xl
|
1rem
via --spacing-lg |
16px – between content groups (e.g. nav sections) |
One gap between an icon and the label it sits beside, so a button, a menu row and a split button all read as the same control family.
| Preview | Token | Value | Notes |
|---|---|---|---|
--spacing-icon-gap
|
0.5rem
via --spacing-sm |
8px – icon-to-label (e.g. buttons, menu rows) |
Stack: vertical gaps between sibling elements
┌─────────┐
│ A │
└─────────┘
↕
┌─────────┐
│ B │
└─────────┘
Use for: column gaps, heading-to-body, form fields, stacked lists, book detail sections
| Preview | Token | Value | Notes |
|---|---|---|---|
--spacing-stack-xs
|
0.25rem
via --spacing-2xs |
4px – tight text runs (e.g. title + subtitle) | |
--spacing-stack-sm
|
0.5rem
via --spacing-sm |
8px – related elements (e.g. form label to input) | |
--spacing-stack-md
|
1rem
via --spacing-lg |
16px – default stack gap (e.g. book detail fields) | |
--spacing-stack-lg
|
1.5rem
via --spacing-2xl |
24px – between content blocks (e.g. edition groups) | |
--spacing-stack-xl
|
2rem
via --spacing-3xl |
32px – between major sections (e.g. work tabs) | |
--spacing-stack-2xl
|
3rem
via --spacing-5xl |
48px – editorial air (e.g. above a section heading in long-form prose) |
Inset: padding inside a container
┌─────────────────┐ │ ↑ │ │ ← [ content ] → │ │ ↓ │ └─────────────────┘
Use for: padding on cards, dialogs, buttons, tags, read-more panels, search result items
| Preview | Token | Value | Notes |
|---|---|---|---|
--spacing-inset-xs
|
0.25rem
via --spacing-2xs |
4px – badges, tags (e.g. subject pills) | |
--spacing-inset-sm
|
0.5rem
via --spacing-sm |
8px – buttons, small cards (e.g. pagination links) | |
--spacing-inset-md
|
1rem
via --spacing-lg |
16px – cards, dialogs (e.g. read-more panel) | |
--spacing-inset-lg
|
1.5rem
via --spacing-2xl |
24px – panels, sections (e.g. book overview card) | |
--spacing-inset-xl
|
2rem
via --spacing-3xl |
32px – hero areas, modals (e.g. welcome banner) |
Pairs with --border-card and --border-radius-card, so cards side by side share one padding.
| Preview | Token | Value | Notes |
|---|---|---|---|
--spacing-inset-card
|
1.5rem
via --spacing-inset-lg |
24px – content cards (e.g. home library row) |
Layout: page-level spacing
│←gutter→┌──────────┐←gutter→│ │ │ section │ │ │ └──────────┘ │ │ ↕ section-gap │ │ ┌──────────┐ │ │ │ section │ │ │ └──────────┘ │
Use for: page edge margins, gaps between top-level page sections (e.g. header → carousel → lists)
| Preview | Token | Value | Notes |
|---|---|---|---|
--spacing-page-gutter
|
1.25rem
via --spacing-xl |
20px – page edge (#contentBody); 12px on phones, below | |
--spacing-section-gap
|
2rem
via --spacing-3xl |
32px – between page sections | |
--spacing-section-gap-desktop
|
3rem
via --spacing-5xl |
48px – between page sections on desktop |
Border Radius
Border-Radius Primitives
Rarely used directly in components - use semantic tokens instead
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-radius-sm
|
4px
|
||
--border-radius-md
|
8px
|
||
--border-radius-lg
|
12px
|
||
--border-radius-xl
|
16px
|
||
--border-radius-xxl
|
32px
|
||
--border-radius-pill
|
9999px
|
||
--border-radius-circle
|
50%
|
||
--border-radius-square
|
0
|
Border-Radius Semantic Tokens
Reference these throughout the app for consistent border-radius styling.
The step carries the meaning, so a card and a dialog don't end up the same shape:
lg 12px floating — it sits above the page: dialog, popover, toast, tray md 8px in-page — it sits in the page: card, banner, button, input, row sm 4px object — it is a small thing in its own right: badge, tag
A bigger box reads squarer at the same radius, which is why one step covers both a 32px button and an 800px card without either looking wrong.
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-radius-button
|
8px
via --border-radius-md |
buttons, tabs | |
--border-radius-input
|
8px
via --border-radius-md |
inputs, textareas |
Book covers, author portraits, illustrations.
A printed object has a near-square corner — a cover rounded like a control reads as an app tile rather than a book — and covers are the most-repeated shape on the site.
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-radius-thumbnail
|
4px
via --border-radius-sm |
covers, small images | |
--border-radius-card
|
8px
via --border-radius-md |
cards, page shell | |
--border-radius-notification
|
8px
via --border-radius-md |
notifications, alerts, banners | |
--border-radius-overlay
|
12px
via --border-radius-lg |
dialogs, modals, popovers, toasts |
Rows inside an overlay.
Derived, not chosen: a row inset --menu-row-inset (4px) from a 12px panel is concentric at 12 - 4 = 8px. Change one and the other has to move, or the two corners stop sharing a centre.
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-radius-menu-row
|
8px
via --border-radius-md |
menu rows, option rows | |
--border-radius-tooltip
|
8px
via --border-radius-md |
tooltips |
Not --border-radius-pill: a chip that wraps to two lines under a 9999px radius reads as a lozenge with a dent, so this is a fixed step that holds up at either height.
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-radius-chip
|
16px
via --border-radius-xl |
chips, pills that may wrap | |
--border-radius-badge
|
4px
via --border-radius-sm |
badges, tags | |
--border-radius-avatar
|
50%
via --border-radius-circle |
avatars, profile pictures |
Borders
Border Primitives
Rarely used directly in components - use semantic tokens instead
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-width-none
|
0
|
||
--border-width-thin
|
1px
|
||
--border-width
|
1px
|
||
--border-width-thick
|
2px
|
||
--border-width-heavy
|
3px
|
Border Semantic Tokens
Reference these throughout the app for consistent border styling.
You should be able to use the complete border style variables in most cases, instead of the border width variables.
Border Widths
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-width-control
|
1px
|
inputs, buttons | |
--border-width-media
|
1px
|
images, media objects | |
--border-width-table
|
1px
|
||
--border-width-divider
|
1px
|
||
--focus-width
|
2px
|
||
--pointed-ring-width
|
4px
|
ring hugging the item under the pointer, e.g. a hovered book cover |
Complete border styles.
Every hairline is --color-border-subtle, so cards tables, and inputs share one warm line.
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-card
|
1px solid var(--color-border-subtle)
|
||
--border-divider
|
1px solid var(--color-border-subtle)
|
||
--border-table
|
1px solid var(--color-border-subtle)
|
||
--border-input
|
1px solid var(--color-border-subtle)
|
||
--border-input-hovered
|
1px solid var(--color-border-focused)
|
||
--border-input-focused
|
1px solid var(--color-border-focused)
|
Focus Ring — accessible focus indicator
| Preview | Token | Value | Notes |
|---|---|---|---|
--box-shadow-focus
|
0 0 0 2px var(--color-focus-ring)
|
Surface a raised control sits on.
Components override it alongside their own background-color so the specular highlight tones to that fill, not white.
| Preview | Token | Value | Notes |
|---|---|---|---|
--control-surface
|
hsl(0, 0%, 100%)
via --white |
Specular edge strength on a saturated fill (primary, danger, a selected chip). White reads much louder on a dark fill than on the neutral one's 35%, so this is tuned to land as quietly as the neutral edge does.
| Preview | Token | Value | Notes |
|---|---|---|---|
--control-highlight-strength-filled
|
8%
|
Raised-control drop shadow — the surface-independent half of the lift.
The matching inset highlight is deliberately not in this token: a :root custom property resolves its var()s against :root, so --control-surface would bake to white and element-level overrides could never reach it. Components apply it inline instead, as inset 0 1px 0 color-mix(in srgb, var(--white) 35%, var(--control-surface)). Alpha is on the --ink-overlay hue, and stays literal — var() can't carry it.
| Preview | Token | Value | Notes |
|---|---|---|---|
--box-shadow-raised
|
0 1px 1px hsla(44, 60%, 18%, 0.07)
|
Floating-control drop shadow — a control that sits over content (a save button on cover art) rather than on the page. Pair with the same inline specular highlight as above; see ol-button[elevation="floating"].
| Preview | Token | Value | Notes |
|---|---|---|---|
--box-shadow-floating
|
0 1px 4px hsla(44, 60%, 18%, 0.15)
|
Overlay surface — the floating panel behind popovers, menus, dialogs, and toasts. The hairline border draws the edge that a soft shadow alone loses against a white page; because the border carries the definition, the shadow can stay light and read as depth rather than as a smudge. Shares the divider tone, so an overlay's edge and the rules inside it are the same value.
| Preview | Token | Value | Notes |
|---|---|---|---|
--border-overlay
|
1px solid var(--color-border-subtle)
|
||
--box-shadow-overlay
|
0 1px 2px hsla(44, 60%, 18%, 0.04), 0 8px 24px hsla(44, 60%, 18%, 0.09)
|
The scrim behind a modal overlay — the dialog's ::backdrop, the drawer's scrim, the popover's mobile tray, which should all read as the same gesture. The blur is what pushes the page back; the dim is what makes it inert. Blur follows modality, never a breakpoint; a non-modal overlay gets neither. See docs/frontend/design.md#blur-follows-modality-not-viewport-width.
| Preview | Token | Value | Notes |
|---|---|---|---|
--overlay-backdrop-color
|
hsla(0, 0%, 0%, 0.32)
|
||
--overlay-backdrop-blur
|
3px
|
Control Heights
Design approach to Control Heights
- One source of truth for the outer height of single-line, line-height-locked controls (buttons, segmented controls, inputs, selects).
- Controls set
height(not vertical padding) to one of these tokens withbox-sizing: border-box, so same-size controls line up exactly regardless of their font-size, border-width, or content height. See /developers/design ("Control alignment") for the side-by-side check.
Do NOT use these for controls whose content can wrap to multiple lines (chips/tags, textareas) — those should grow with their content. Use min-height there instead, or a different scale (e.g. --line-height-chip).
| Preview | Token | Value | Notes |
|---|---|---|---|
--control-height-small
|
26px
|
||
--control-height-medium
|
32px
|
||
--control-height-large
|
36px
|
Menu rows are a different shape: rows inside a panel — a popover, a drawer — sized to be comfortably tappable rather than to line up beside a button. Applied as min-height, never height, so a described row can grow past it; keep the row's own vertical padding under it.
The row is inset from the panel by --menu-row-inset and rounded so its hover fill reads as a pill, then gives that inset back as --menu-row-padding-inline to hold the label column 16px from the panel. Light-DOM panels that can't margin the row put the inset on their own side padding instead.
| Preview | Token | Value | Notes |
|---|---|---|---|
--menu-row-height
|
36px
|
||
--menu-row-inset
|
0.25rem
via --spacing-inset-xs |
||
--menu-row-padding-inline
|
calc(var(--spacing-inset-md) - var(--menu-row-inset))
|
Press
Press feedback scale
Controls squeeze on :active. The percentage is tiered by width so the edge always travels about 1.5px: 3% on a 32px icon button is sub-pixel, 3% on a 500px search bar is a lurch. Pick by width, not importance — compact for square icon controls, default for text, wide for stretched ones. Only self-contained controls scale; menu rows and surfaces never do.
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--press-scale-compact
|
0.92
|
|
| — |
--press-scale
|
0.97
|
|
| — |
--press-scale-wide
|
0.985
|
Z Index
Z-Index Primitives
Raw stacking values. Components use the semantic tokens below, never these (Stylelint enforces it). Gaps in numbering are deliberate; do not fill them.
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--z-index-level-negative
|
-1
|
|
| — |
--z-index-level-1
|
1
|
|
| — |
--z-index-level-2
|
2
|
|
| — |
--z-index-level-3
|
3
|
|
| — |
--z-index-level-4
|
10
|
|
| — |
--z-index-level-5
|
999
|
|
| — |
--z-index-level-13
|
9999
|
|
| — |
--z-index-level-14
|
10000
|
|
| — |
--z-index-level-16
|
99999
|
|
| — |
--z-index-level-17
|
999999
|
Z-Index Semantic Tokens
Three kinds of layering; only page chrome uses these tokens.
- Overlays (dialogs, drawers, popovers, tooltips, menus) use the browser's top layer via <dialog>.showModal() or the Popover API (see components/lit/utils/top-layer.js). No z-index; last shown is on top.
- Page chrome (sticky headers, nav bars, toolbars) uses the bands below,
behindtofixed. Need more thanfixed? It's an overlay: use the top layer. - Component internals set
isolation: isolateand use --z-index-local-*.
The bands above fixed are legacy, for pre-top-layer overlays (jQuery UI, autocomplete, dropper, nav-bar menus, toasts). Add no new consumers. Anything in the top layer paints above even toast.
Rules:
- No raw z-index numbers and no --z-index-level-* primitives.
- A shadow root is not a stacking context; isolate an ancestor.
- Ties within a band resolve by document order.
Layer hierarchy (high → low):
toast --z-index-toast 999999 ← legacy: .toast-container
overlay --z-index-overlay 99999 ← legacy: jQuery autocomplete, manage-covers
modal --z-index-modal 9999 ← legacy: OTP overlay scrim
dropdown --z-index-dropdown 999 ← legacy: language selector, nav-bar, compact-title;
popover/tooltip fallback without the Popover API
fixed --z-index-fixed 10 ← toolbars over content
sticky --z-index-sticky 3 ← mobile site header
raised --z-index-raised 2 ← one step above base
base --z-index-base 1 ← minimum lift above static flow
behind --z-index-behind -1 ← behind parent content
10000 (--z-index-level-14) has no band; its few legacy uses carry an inline note. There is no zero token: use isolation: isolate instead.
Keep these columns aligned (lookup table).
prettier-ignore
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--z-index-behind
|
-1
via --z-index-level-negative |
1 – behind parent (e.g. .editionCover animation) |
| — |
--z-index-base
|
1
via --z-index-level-1 |
1 – e.g. chevrons, sticky compare card |
| — |
--z-index-raised
|
2
via --z-index-level-2 |
2 – e.g. page-banner, header-bar items |
| — |
--z-index-sticky
|
3
via --z-index-level-3 |
3 – #header-bar at ≤960px |
| — |
--z-index-fixed
|
10
via --z-index-level-4 |
10 – e.g. ILE toolbar, LibraryToolbar, dropper-menu |
| — |
--z-index-dropdown
|
999
via --z-index-level-5 |
999 – legacy floating panels |
| — |
--z-index-modal
|
9999
via --z-index-level-13 |
9999 – legacy: OTP overlay scrim |
| — |
--z-index-overlay
|
99999
via --z-index-level-16 |
99999 – legacy: autocomplete, manage-covers |
| — |
--z-index-toast
|
999999
via --z-index-level-17 |
999999 – legacy toasts |
Local Layers
Ordinals for layering inside a component whose root sets isolation: isolate. Meaningless without it. Use --z-index-behind for negative layers.
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--z-index-local-1
|
1
|
|
| — |
--z-index-local-2
|
2
|
|
| — |
--z-index-local-3
|
3
|
|
| — |
--z-index-local-4
|
4
|
|
| — |
--z-index-local-5
|
5
|
Breakpoints
For reference only — CSS custom properties cannot be used in @media queries. When using in @media, hardcode the px value and add a comment.
Example:
@media (min-width: 768px) { ... } (--width-breakpoint-tablet)
| Preview | Token | Value | Notes |
|---|---|---|---|
--width-breakpoint-mobile-s
|
375px
|
||
--width-breakpoint-mobile-m
|
425px
|
||
--width-breakpoint-mobile
|
450px
|
||
--width-breakpoint-tablet
|
768px
|
48em | |
--width-breakpoint-desktop
|
960px
|
60em |
Icon Sizes
The size scale for <ol-icon> and the $:macros.icon() macro, in rem so icons follow the reader's font size.
Stroke is in 24x24 grid units, which the box scales down with the geometry. Held at 2 throughout, lg would land at a full 2px and read heavier than the same glyph one step down, so it is corrected to 1.75 — keeping absolute weight near-constant across the scale.
Keep these columns aligned (lookup table).
prettier-ignore
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--icon-size-sm
|
1rem
|
16px – inline with body text, dense toolbars |
| — |
--icon-size-md
|
1.25rem
|
20px – default |
| — |
--icon-size-lg
|
1.5rem
|
24px – standalone buttons, headers |
| — |
--icon-stroke-sm
|
2
|
renders 1.33px |
| — |
--icon-stroke-md
|
2
|
renders 1.67px |
| — |
--icon-stroke-lg
|
1.75
|
renders 1.75px – optically corrected, see above |
Motion
Durations and easing. Two tiers, like colors: primitives are named Penner curves; semantic tokens name what a component is doing. Reach for the semantic token; add a primitive only when a semantic token references it. Hover never transitions color (see docs/frontend/design.md, "Animations").
Primitives
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--ease-out-quart
|
cubic-bezier(0.165, 0.84, 0.44, 1)
|
|
| — |
--ease-out-quint
|
cubic-bezier(0.23, 1, 0.32, 1)
|
|
| — |
--ease-in-out-cubic
|
cubic-bezier(0.645, 0.045, 0.355, 1)
|
|
| — |
--ease-out-back
|
cubic-bezier(0.34, 1.56, 0.64, 1)
|
Semantic easing
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--ease-enter
|
cubic-bezier(0.23, 1, 0.32, 1)
via --ease-out-quint |
a surface appearing: popover, drawer, toast, banner |
| — |
--ease-exit
|
cubic-bezier(0.23, 1, 0.32, 1)
via --ease-out-quint |
the same surface leaving, at a shorter duration |
| — |
--ease-move
|
cubic-bezier(0.645, 0.045, 0.355, 1)
via --ease-in-out-cubic |
something already on screen repositioning |
| — |
--ease-state
|
ease
|
checked / selected / disabled changes — never hover |
| — |
--ease-spring
|
cubic-bezier(0.34, 1.56, 0.64, 1)
via --ease-out-back |
a small object springing into a pose, overshooting a little — never a surface |
Durations
| Preview | Token | Value | Notes |
|---|---|---|---|
| — |
--duration-press
|
80ms
|
transform on :active |
| — |
--duration-fast
|
150ms
|
small state changes |
| — |
--duration-base
|
200ms
|
enter/exit of small and medium surfaces |
| — |
--duration-slow
|
300ms
|
exit of large surfaces |
| — |
--duration-slower
|
400ms
|
enter of large surfaces: drawer, toast |
| — |
--duration-spin
|
700ms
|
one revolution of any spinner, linear |
| — |
--duration-shimmer
|
1600ms
|
one sweep of a loading skeleton's highlight, linear |