The design system, showing itself
Design tokens
Every colour, distance, corner, shadow and curve in mySpoils is a named token, shown here in both themes at once. The page is generated from the stylesheet at build time, so it cannot fall behind what ships. Contrast is measured in the browser rather than claimed.
- 119
- 115
- 4
- 16
Surfaces
Light is the pre-theming palette, value for value: neutral-50 page, white cards, amber-50/40-over-white inset, neutral-100 subtle.
--surface-pagelight
lab(98.26% 0 0)
dark
oklch(0.155 0.005 65)
--surface-cardlight
#ffffff
dark
oklch(0.207 0.006 65)
--surface-raisedlight
#ffffff
dark
oklch(0.247 0.007 65)
--surface-insetlight
color-mix(in oklab, lab(98.6252% -.635922 8.42309) 40%, transparent)
dark
oklch(0.237 0.015 72)
--surface-sunkenlight
lab(98.26% 0 0)
dark
oklch(0.183 0.005 65)
--surface-subtlelight
lab(96.52% -.0000298023 .0000119209)
dark
oklch(0.262 0.005 65)
--surface-fieldlight
#ffffff
dark
oklch(0.238 0.006 65)
--skeletonlight
oklch(0 0 0 / 0.06)
dark
oklch(1 0 0 / 0.06)
Ink
The original neutral ramp, with one deliberate exception: --ink-meta and --ink-faint (dates, counts, hints, placeholders) were neutral-400 at ~2.5:1, which is a legibility problem rather than a conformance technicality. …
--ink-primarylight
#111111
dark
oklch(0.965 0.004 80)
--ink-stronglight
lab(27.036% 0 0)
dark
oklch(0.905 0.005 75)
--ink-bodylight
lab(34.924% 0 0)
dark
oklch(0.87 0.005 75)
--ink-mutedlight
lab(48.496% 0 0)
dark
oklch(0.745 0.006 72)
--ink-metalight
#707070
dark
oklch(0.64 0.006 72)
--ink-faintlight
#707070
dark
oklch(0.665 0.006 72)
--ink-ghostA placeholder and a disabled label rather than body copy - the one step in the ramp that is meant to recede. Measured for the record, not held to AA.
light
lab(84.92% 0 -.0000119209)
dark
oklch(0.45 0.006 65)
--ink-onwashWhite in both themes, and its ground is a category wash or a poster gradient rather than a surface. Every category derives its own wash from a single accent under a lightness clamp, so this one is measured per category by the colour audit rather than once here.
light
#ffffff
dark
oklch(1 0 0)
Lines
Four hairlines and a track, from the faintest divider to the edge of a control. The step between them is lightness alone in light; dark re-picks them, because a border that only separates and a border that bounds a control diverge once the page behind them is dark.
--line-soft3:1 is the non-text figure, and a hairline that only separates two blocks of the same thing does not owe it. Where a line is the whole edge of a control, it does - see
--line-strongand--track.light
lab(96.52% -.0000298023 .0000119209)
dark
oklch(0.255 0.006 65)
--line-coollight
lab(96.1596% -.0823438 -1.13575)
dark
oklch(0.258 0.006 65)
--linelight
lab(90.952% 0 -.0000119209)
dark
oklch(0.302 0.006 65)
--line-stronglight
lab(84.92% 0 -.0000119209)
dark
oklch(0.385 0.007 65)
--trackAn unchecked switch track, which is a control rather than a hairline: this one is a filled affordance and 3:1 is the figure to read it against.
light
lab(90.952% 0 -.0000119209)
dark
oklch(0.405 0.006 65)
Brand
#6366f1 is a fixed brand value and does not move. --brand-ink (links and inline brand text) is the same colour in light, as it shipped; only dark lifts it, where there is no prior version to preserve.
--brandlight
#6366f1
dark
oklch(0.572 0.2 277)
--brand-hoverlight
#5558e3
dark
oklch(0.625 0.19 277)
--brand-fillThe one brand value painted behind white text. Its note claims 5.11:1; this is that claim measured.
light
#585beb
dark
oklch(0.55 0.2 277)
--brand-inklight
#5a5be5
dark
oklch(0.795 0.115 277)
--brand-fglight
#ffffff
dark
oklch(1 0 0)
--brand-tintlight
lab(95.4818% .411302 -6.78529)
dark
oklch(0.285 0.065 277)
--brand-edgelight
lab(91.6577% 1.04591 -12.7199)
dark
oklch(0.36 0.08 277)
--brand-deeplight
lab(32.4486% 49.2217 -84.6695)
dark
oklch(0.86 0.09 277)
--brand-ringlight
#6366f1
dark
oklch(0.705 0.16 277)
Inverse
For the handful of elements that deliberately oppose the page: selected filter chips, tooltips, the toast. These have to actually invert rather than darken, or a "selected" chip stops reading as selected the moment the page around it is already dark.
--surface-inverselight
lab(15.204% 0 -.00000596046)
dark
oklch(0.935 0.006 75)
--surface-inverse-stronglight
lab(7.78201% -.0000149012 0)
dark
oklch(0.965 0.005 75)
--surface-inklight
#111111
dark
oklch(0.955 0.005 75)
--ink-inverselight
#ffffff
dark
oklch(0.17 0.006 65)
Status
Each has a tint (the panel) and an ink (the copy on it), measured as a pair so a soft notice reads the same way in both themes.
--positiveA tick, not copy. Every
text-site is a 16px glyph beside its own label, so the figure is a reference and 4.5:1 is not the bar it answers to.light
lab(66.9756% -58.27 19.5419)
dark
oklch(0.755 0.15 158)
--positive-stronglight
lab(55.0481% -49.9246 15.93)
dark
oklch(0.79 0.14 158)
--positive-tintlight
lab(97.8462% -6.94966 1.85487)
dark
oklch(0.285 0.05 158)
--positive-inklight
lab(35.3675% -33.1188 8.04002)
dark
oklch(0.825 0.13 158)
--cautionA warning glyph, not copy. Every
text-site is a 16px glyph beside its own label, so the figure is a reference and 4.5:1 is not the bar it answers to.light
lab(72.7183% 31.8672 97.9407)
dark
oklch(0.79 0.13 80)
--caution-stronglight
lab(47.2709% 42.9082 69.2966)
dark
oklch(0.755 0.13 60)
--caution-tintlight
lab(98.6252% -.635922 8.42309)
dark
oklch(0.295 0.05 80)
--caution-inklight
lab(31.2288% 30.2627 40.0378)
dark
oklch(0.855 0.11 82)
--caution-edgelight
lab(95.916% -1.21653 23.111)
dark
oklch(0.4 0.07 80)
--criticalBoth a glyph colour and the ink of an error line, across fifty-one sites, which is two grounds and two thresholds rather than one pairing the stylesheet states. The measurement stands; the verdict belongs to
--critical-ink, which is the documented pair.light
lab(48.4493% 77.4328 61.5452)
dark
oklch(0.71 0.185 25)
--critical-softlight
lab(55.4814% 75.0732 48.8528)
dark
oklch(0.745 0.17 25)
--critical-tintlight
lab(96.5005% 4.18508 1.52328)
dark
oklch(0.285 0.06 25)
--critical-inklight
#e7000b
dark
oklch(0.805 0.13 25)
--critical-edgelight
lab(86.017% 19.8815 7.75869)
dark
oklch(0.4 0.09 25)
--critical-fillA fill in both themes rather than a value that flips role. Its note claims 4.77:1 in light and 5.43:1 in dark.
light
lab(48.4493% 77.4328 61.5452)
dark
oklch(0.55 0.22 25)
--ratingA filled star against an empty one. Non-text, and the pairing that matters is the two against each other rather than either against the card.
light
lab(80.1641% 16.6016 99.2089)
dark
oklch(0.82 0.15 80)
--rating-emptylight
lab(90.952% 0 -.0000119209)
dark
oklch(0.36 0.006 65)
Favourite
A rose, not the destructive red: marking a favourite is not an error state. On the card it paints inside the marker chip, whose surface flips with the theme, so this flips with it - deep enough to hold on a near-white chip here, lifted in dark.
--favoritelight
oklch(0.62 0.22 14)
dark
oklch(0.72 0.18 14)
--favorite-inklight
oklch(from var(--favorite) 0.56 c h)
dark
oklch(from var(--favorite) 0.78 calc(c * 0.62) h)
--favorite-tintlight
oklch(from var(--favorite) 0.955 calc(c * 0.16) h)
dark
oklch(from var(--favorite) 0.235 calc(c * 0.4) h)
Accent well
Empty-state icon wells. Violet, deliberately a step off the indigo brand, which is how it shipped.
--well-tintlight
lab(93.0838% 4.35197 -9.88284)
dark
oklch(0.29 0.07 292)
--well-inklight
lab(35.2783% 67.9912 -88.793)
dark
oklch(0.8 0.13 292)
--well-edgelight
lab(87.0888% 8.53688 -19.4189)
dark
oklch(0.4 0.09 292)
Extended ramps
Further steps in ramps the families above already own, for the places that need one between the named roles. Indigo at five levels, two cool greys, a warm caution tint and a deeper critical red. …
--brand-vivid--brand-strong--brand-light--brand-pale--brand-edge-strong--brand-deepest--ink-cool--ink-cool-ghost--well-tint-soft--caution-tint-warm--caution-edge-strong--caution-deepest--critical-light--positive-light
Brand mark
The wordmark's two colours: a near-black "my" and the brand indigo on "Spoils". The neutral inverts in dark; the indigo follows --brand into whatever --brand is there. --logo-brand is --brand, exactly, and has no value of its own. …
--logo-inklight
#0e101d
dark
oklch(0.96 0.008 265)
--logo-brandlight
var(--brand)
dark
var(--brand)
--logo-heartlight
#000000
dark
#ffffff
Avatar fallback
The warm gradient behind a user's initials. Kept as its own token rather than reusing --surface-inset: it needs enough chroma to read as a portrait slot, not as a well.
--avatar-fromlight
lab(95.916% -1.21653 23.111)
dark
oklch(0.34 0.055 78)
--avatar-tolight
lab(94.7127% 3.58394 14.3151)
dark
oklch(0.305 0.06 52)
--avatar-inklight
lab(37.8822% 37.1699 52.2718)
dark
oklch(0.875 0.075 78)
Trophy
The one card in the product that holds what the *reader* put there: "My experience" on the spoil sheet. Nothing else takes it - a panel that is merely celebratory is not this, and the moment a second kind of panel wants it, one of the two has the wrong token. …
--surface-trophylight
oklch(0.975 0.024 86)
dark
oklch(0.216 0.022 68)
--ring-trophylight
rgb(146 92 20 / 0.16)
dark
oklch(0.72 0.06 70 / 0.22)
Elevation
In light the shadow carries the lift and the ring is a faint warm edge. In dark that inverts: the ring carries it, the shadow only deepens.
--ring-cardlight
rgb(70 25 1 / 0.05)
dark
oklch(1 0 0 / 0.075)
--ring-insetlight
rgb(123 51 6 / 0.06)
dark
oklch(1 0 0 / 0.07)
--ring-card-hoverlight
rgb(0 0 0 / 0.08)
dark
oklch(1 0 0 / 0.13)
--shadow-cardlight
0 2px 10px -2px oklch(0 0 0 / 0.06), 0 1px 4px oklch(0 0 0 / 0.03)
dark
0 2px 10px -2px oklch(0 0 0 / 0.5), 0 1px 4px oklch(0 0 0 / 0.35)
--shadow-tilelight
0 2px 10px -2px oklch(0 0 0 / 0.08), 0 1px 4px oklch(0 0 0 / 0.04)
dark
0 2px 10px -2px oklch(0 0 0 / 0.55), 0 1px 4px oklch(0 0 0 / 0.4)
--shadow-card-hoverlight
0 4px 14px -2px oklch(0 0 0 / 0.1), 0 2px 6px oklch(0 0 0 / 0.05)
dark
0 6px 18px -4px oklch(0 0 0 / 0.6), 0 2px 8px oklch(0 0 0 / 0.4)
--shadow-menulight
0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.06)
dark
0 14px 38px -10px oklch(0 0 0 / 0.7), 0 3px 10px oklch(0 0 0 / 0.45)
--shadow-toastlight
0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)
dark
0 20px 25px -5px oklch(0 0 0 / 0.7), 0 8px 10px -6px oklch(0 0 0 / 0.55)
--ring-menulight
rgb(0 0 0 / 0.04)
dark
oklch(1 0 0 / 0.08)
--ring-thumblight
rgb(0 0 0 / 0.06)
dark
oklch(1 0 0 / 0.1)
Overlays
Scrims and the two surfaces that do not flip. Dialogs dim the page and no more; a trailer removes it. --surface-cinema and --surface-artwork are the only two values in the product that are the same in both themes, and each has a paragraph in the stylesheet saying why.
--scrimlight
rgb(0 0 0 / 0.4)
dark
rgb(0 0 0 / 0.55)
--scrim-softlight
rgb(0 0 0 / 0.3)
dark
rgb(0 0 0 / 0.45)
--overlaylight
rgb(0 0 0 / 0.1)
dark
oklch(0.1 0.008 65 / 0.55)
--overlay-stronglight
rgb(0 0 0 / 0.2)
dark
oklch(0.1 0.008 65 / 0.68)
--overlay-cinemalight
rgb(0 0 0 / 0.78)
dark
rgb(0 0 0 / 0.82)
--surface-cinemalight
oklch(0.17 0.006 65)
dark
oklch(0.17 0.006 65)
--surface-artworklight
oklch(0.972 0.003 75)
dark
oklch(0.972 0.003 75)
--scrollbar-thumblight
oklch(0 0 0 / 0.18)
dark
oklch(1 0 0 / 0.2)
--scrollbar-thumb-hoverlight
oklch(0 0 0 / 0.3)
dark
oklch(1 0 0 / 0.32)
Category derivation
Components set --cat-accent (and --cat-surface) inline from the one category catalogue. Everything else is derived here, so a category's colour is authored exactly once. The lightness clamp is what makes every badge AA by construction: …
--cat-accentlight
#6B7280
dark
#6B7280
--cat-ink-lightlight
#6B7280
dark
#6B7280
--cat-surfacelight
#F3F4F6
dark
#F3F4F6
--cat-borderlight
#E5E5E5
dark
#E5E5E5
--radiuslight
0.625rem
dark
0.625rem
shadcn compatibility layer
Not a palette. This is the surface shadcn/ui's primitives read from, restored to its pre-theming values verbatim and deliberately held still: aliasing these onto the semantic tokens above shifted defaults everywhere - #0a0a0a body ink became #111, neutral greys moved 2-11 units - so light keeps the originals and only .dark does the aliasing. Nothing new should be built against these names; they exist so that what was built against them does not move.
--background--foreground--card--popover--popover-foreground--primary--primary-foreground--secondary--secondary-foreground--muted--muted-foreground--destructive--border--input--ring--cookie-banner-height
Radius
One authored value, --radius at 0.625rem, and six multipliers of it. Nothing here is themed - a corner is a shape rather than a colour - so these are shown once.
smmdlgxl2xl3xl4xl
Elevation, as a pair
A shadow and a ring per step, and the two themes divide the work differently: light lifts with the shadow and edges with the ring, dark reverses it, because a drop shadow stops reading once the page behind it is dark. Each row is the same two tokens painted twice.
--shadow-cardAn entry card.
--shadow-tileA collection tile, a step above a card.
--shadow-card-hoverThe lift under a pointer.
--shadow-menuA dropdown panel.
--shadow-toastThe loudest step.
Motion
Nine curves, plotted from their own control points. The two figures are computed here rather than quoted: how much of the duration has passed when 90% of the travel is done, which is what a motion reads as, and the pass beyond the target, which is what it reads as having weight.
What each one is for is a different question and is settled elsewhere - MOTION.md for the rules and the list of what never moves, and /preview/motion for the tiers, which writes its values out as literals on purpose so it can disagree with the stylesheet.
--ease-settle90% of the travel at 33% of the duration, no overshoot.
--ease-bounce90% of the travel at 15% of the duration, passing the target by 17.7%.
--ease-press90% of the travel at 74% of the duration, no overshoot.
--ease-weight90% of the travel at 58% of the duration, no overshoot.
--ease-spoil-leave90% of the travel at 50% of the duration, no overshoot.
--ease-spoil-arrive90% of the travel at 17% of the duration, passing the target by 8%.
--ease-spoil-pop90% of the travel at 16% of the duration, passing the target by 19.9%.
--ease-spring90% of the travel at 30% of the duration, passing the target by 9.8%.
--ease-overshootAn alias of
--ease-spring, so nothing that reads it moves differently.
Spacing
Six steps, named for the job rather than the size, and direction-agnostic because they are gap-*. The brand stripe in each row is the gap itself, cut out of a solid strip by three blocks that are the same size in every row, so the only thing that changes down the column is the distance being named. Each one is laid out with its own class, so these are distances rather than pictures of distances. pagePaddingX is deliberately off this scale, since it is measured against the edge of the screen rather than the content rhythm.
spaceInlineInside one control: an icon and its label.
spaceFieldA label to its control. Things that are one thing.
spaceClusterA second cluster inside a row - and the step enclosed rows take, where a border already separates them.
spaceRowThe workhorse: one field to the next, where nothing but space separates them.
spaceBlockOne block to the next inside a section.
spaceSectionOne section to the next. The largest distance the product needs.
Type
Eight named roles, each a bundle of face, size, weight, tracking and ink, named for where it goes rather than how big it is. The three faces are below them.
Not yet a scale. There is no named size step between these bundles, so a line that is not one of these roles still reaches for a number of its own. That is the one family on this page with a gap in it.
headingPagePage and section titles, in-app.
Everything you have been to
font-heading text-2xl font-bold tracking-tight text-balance text-ink-primary
headingSectionA subsection title. Steps up at sm.
This year
font-heading text-xl font-bold tracking-tight text-balance text-ink-primary sm:text-2xl
headingEmptyEmpty states and panel titles.
Nothing here yet
font-heading text-lg font-bold tracking-tight text-balance text-ink-primary sm:text-xl
headingEntryA card title in a dense layout. Unbalanced on purpose - it truncates.
The Grand Budapest Hotel
font-heading text-base font-bold tracking-tight leading-snug text-ink-primary
textBodyMutedBalanced, because every site is a sentence or two in a bounded measure.
Secondary copy, which is most of the sentences in the product.
text-sm leading-relaxed text-balance text-ink-muted
textMetaDates, counts, hints. Single line by construction.
text-xs font-medium text-ink-meta
sectionKickerClassThe line above a masthead, saying what the page is a list of. The tracking is the whole effect.
All time, every category
text-xs font-medium uppercase tracking-[0.18em] text-ink-faint
settingsRowHintClassThe explanatory line under a settings row's name.
Choose which collections appear on your profile.
mt-0.5 block text-xs text-balance text-ink-muted
The three faces
Aa
Bricolage Grotesque. Titles. The one voice with personality.
Aa
DM Sans. Everything the reader reads.
Aa
IBM Plex Mono. Values, ids, anything to be copied. Loaded at 700 only.
Focus, and the states
The focus ring is one class - focusRing, which is --brand-ring at 2px and on focus-visible only. Tab through the controls below, in either pane.
These are the product's own Button and IconButton, not look-alikes. The skeleton is the state worth seeing twice: it is translucent ink rather than a colour, so it is the same step from whichever surface is behind it.
light
dark