It looks like you're offline.
Open Library logo

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.

  1. New code uses a semantic token, never a primitive (--neutral-500) or a raw hsl(). If none fits, add one here and name it for its job.
  2. 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 with box-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.

  1. 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.
  2. Page chrome (sticky headers, nav bars, toolbars) uses the bands below, behind to fixed. Need more than fixed? It's an overlay: use the top layer.
  3. Component internals set isolation: isolate and 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:

  1. No raw z-index numbers and no --z-index-level-* primitives.
  2. A shadow root is not a stacking context; isolate an ancestor.
  3. 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