Skip to content
Foundations

Dark mode

Not an inversion: surfaces lighten as they rise, shadows collapse, and the status hues are re-derived.

The surface ladder

--gd-surface-raised, on --gd-surface
--gd-surface-raised is the one to reach for when a thing genuinely floats. In light it resolves to the same white as --gd-surface and the shadow carries the lift; in dark it is one step lighter and the surface carries it instead.
surface
--gd-surface#FFFFFF#17181E

the default ground for cards, panels and rows

--gd-surface-page#F4F4F2#101116

the page behind everything — warm off-white in the light, the lowest charcoal in the dark

--gd-surface-subtle#FBFBFC#1D1F26

table headers and glyph-tile grounds: a surface that is not quite the card it sits on

--gd-surface-raised#FFFFFF#1D1F26

a surface lifted off its ground — a floating pill, a popover, a menu. In light the shadow carries it and this equals `surface`; in dark the surface itself has to

--gd-surface-sunken#F4F4F7#101116

recessed groups — segmented tracks, icon tiles, layer lists. Sunken is the one people get wrong: a GROUP of things is recessed, not raised

--gd-surface-hover#FAFAFB#24262E

pointer over a row. Hover is a SURFACE change, never a shadow change — a list that lifts under the pointer shimmers

--gd-surface-pressed#F4F4F7#2B2D36

the moment of the click

--gd-surface-selected#EEF0FF#3B3866

the chosen row or option

--gd-surface-disabled#F0F1F4#212329

an inert control's fill

--gd-surface-inverse#14151A#24262E

toasts, code blocks, the ink CTA band — a surface that is dark in BOTH themes

--gd-surface-solid#14151A#F2F2F4

the neutral high-emphasis CTA fill — near-black on a light page, near-white on a dark one. NOT `surface-inverse`, which stays dark in both

--gd-surface-map#F0EEE9#22242B

the basemap ground. Deliberately not black in the dark, so parcel strokes and overlay fills keep the relationship to their ground that they have in daylight

--gd-surface-scrimrgba(20, 21, 26, 0.42)rgba(0, 0, 0, 0.55)

the dimmer behind modal surfaces — dialog, drawer, palette. Deeper in the dark, because 42% charcoal over a charcoal page barely reads as a veil

--gd-surface-on-accentrgba(255, 255, 255, .10)rgba(255, 255, 255, .10)

a card or row sitting ON a filled accent band

--gd-surface-on-accent-strongrgba(255, 255, 255, .16)rgba(255, 255, 255, .16)

a chip on a filled accent band — one step up from the card it sits in

Re-derived status

success
--gd-success#1B8A4C#8FBF87

success fill — dots, icon tiles, progress bars. Never set type in it AA-nontext on --gd-surface

--gd-success-ink#15703D#8FBF87

the only success value that may carry text. Re-derived for dark rather than lightened — a tint that reads as caution on white reads as sickly on charcoal AA on --gd-surface

--gd-success-wash#F1F7F2color-mix(in oklab, #8FBF87 14%, #17181E)

the success ground — alerts, callouts, washed chips

--gd-success-tint#CFE3D5color-mix(in oklab, #8FBF87 34%, #17181E)

the border of a success wash

--gd-success-on-solid#FFFFFF#14151A

the GLYPH on a filled success tile. Non-text: these tiles carry a 15px icon, never prose — success at 4.4:1 would not clear AA for a sentence AA-nontext on --gd-success

info
--gd-info#2F6FB0#7FB3E6

info fill — dots, icon tiles, progress bars. Never set type in it AA-nontext on --gd-surface

--gd-info-ink#285F96#7FB3E6

the only info value that may carry text. Re-derived for dark rather than lightened — a tint that reads as caution on white reads as sickly on charcoal AA on --gd-surface

--gd-info-wash#EDF3FAcolor-mix(in oklab, #7FB3E6 14%, #17181E)

the info ground — alerts, callouts, washed chips

--gd-info-tint#C5DAEEcolor-mix(in oklab, #7FB3E6 34%, #17181E)

the border of a info wash

--gd-info-on-solid#FFFFFF#14151A

the GLYPH on a filled info tile. Non-text: these tiles carry a 15px icon, never prose — success at 4.4:1 would not clear AA for a sentence AA-nontext on --gd-info

caution
--gd-caution#A8850F#DDB63F

caution fill — dots, icon tiles, progress bars. Never set type in it AA-nontext on --gd-surface

--gd-caution-ink#8A6D09#D9B34A

the only caution value that may carry text. Re-derived for dark rather than lightened — a tint that reads as caution on white reads as sickly on charcoal AA on --gd-surface

--gd-caution-wash#FCF6E6color-mix(in oklab, #DDB63F 14%, #17181E)

the caution ground — alerts, callouts, washed chips

--gd-caution-tint#EBD9A2color-mix(in oklab, #DDB63F 34%, #17181E)

the border of a caution wash

--gd-caution-on-solid#FFFFFF#14151A

the GLYPH on a filled caution tile. Non-text: these tiles carry a 15px icon, never prose — success at 4.4:1 would not clear AA for a sentence AA-nontext on --gd-caution

danger
--gd-danger#C43D3D#E97B7B

danger fill — dots, icon tiles, progress bars. Never set type in it AA-nontext on --gd-surface

--gd-danger-ink#A83232#E97B7B

the only danger value that may carry text. Re-derived for dark rather than lightened — a tint that reads as caution on white reads as sickly on charcoal AA on --gd-surface

--gd-danger-wash#FCEFEFcolor-mix(in oklab, #E97B7B 14%, #17181E)

the danger ground — alerts, callouts, washed chips

--gd-danger-tint#EFCACAcolor-mix(in oklab, #E97B7B 34%, #17181E)

the border of a danger wash

--gd-danger-on-solid#FFFFFF#14151A

the GLYPH on a filled danger tile. Non-text: these tiles carry a 15px icon, never prose — success at 4.4:1 would not clear AA for a sentence AA-nontext on --gd-danger

Selecting a theme

// follow the OS — the default, no attribute at all document.documentElement.removeAttribute('data-gd-theme') // pin it document.documentElement.setAttribute('data-gd-theme', 'dark')

Neither hook is a control. The switch a person actually presses is Theme toggle, which cycles all three choices.

Rules

  • Raise a thing with --gd-surface-raised, not with a shadow. In light it equals --gd-surface and the shadow does the work; in dark it is the step that carries the lift.
  • Never fake elevation with a heavier black. The dark shadows are already halved, and everything below float is none.
  • Set accent type in --gd-accent-ink, never --gd-accent. The accent proper is 1.9:1 on charcoal.
  • Never lighten a light-theme status hue to get a dark one. Each dark value is re-derived and lives in primitives.ts.
  • Use --gd-surface-solid for a neutral CTA. --gd-surface-inverse stays dark in both themes and has no visible edge on a dark page.
  • Do not stamp the theme attribute inside the app shell. The toggle deliberately writes nothing before paint — that is a head concern the app has to add for itself.
  • If an app ships light-only, declare color-scheme: light. Do not ship half a palette.

Behavior & Anatomy

Why it is not a token flip

Every page on this site is already in both themes — use the control at the bottom of the nav. Three things had to move independently for that to be true.

Shadows stop working

A shadow against a dark ground is nearly invisible, and faking it with a heavier black produces a smudge. Elevation moves into the surface: lighter is higher.

The accent cannot survive

#4740DB on charcoal is 1.9:1. Links and icons take the lifted #A9A4F2; the filled button keeps a bolder indigo with white text. That is why --gd-accent and --gd-accent-ink are two tokens rather than one.

Status must be re-derived

A tint that reads as caution on white reads as sickly on charcoal. Each hue keeps its identity but gains lightness and loses saturation, so all four clear 4.5:1 on the dark surface.

Why the basemap is not black

--gd-surface-map goes to #22242B rather than black, so parcel strokes and overlay fills keep the same relationship to their ground that they have in daylight.

The surface that carries elevation in the dark

Elevation is carried by different things in the two themes, and only one of them is a shadow. --gd-shadow-md is rgba(0,0,0,.45), which over near-black is very nearly nothing — in the dark, elevation is carried by the SURFACE getting lighter as it rises. A component that genuinely floats and reaches for --gd-surface instead comes out at 1.00 against its own ground, which is the whole mechanism with nothing to say it with.

--gd-surface-raised is that token, and its step is chosen against the light theme's own precedent: a white pill on the page tint is 1.101, and #1D1F26 on #17181E is 1.076 — the same relationship, not a new one.

Why the dark accent ink is measured on two grounds

--gd-accent-ink has two grounds, and both must be measured: the page, where it is a link, and --gd-accent-wash, where it is the label of a latched control — a saved favourite, an active map tool, a selected row. Measure only the page and the wash is where it fails: #8C86F0 is a comfortable 5.72:1 on the surface and 3.49:1 on the wash, which makes a saved star and an unsaved star the same button to look at. #A9A4F2 is the value that clears both, at 7.83 and 4.77.

The wash was NOT darkened instead, and the arithmetic is why: taking it down far enough to carry #8C86F0 at 4.5:1 drops its separation from --gd-surface to about 1.19:1, at which point the selected row no longer reads as selected. That trades a legibility bug for a different one.

Why the dark ink ladder has a fifth step

The design gives four — #F2F2F4 / #C9CBD2 / #9A9EA8 / #74777F — and the last is 3.95:1 on the dark surface, which is under AA for anything that is not large. That is the same shape of problem the light ladder has at #8A8E96, and it gets the same answer: #74777F keeps its place as --gd-ink-faint and a fifth step is interposed for --gd-ink-muted, so eyebrows and captions have somewhere to live that clears AA. Without it, secondary and muted would resolve to the same hex in the dark and a documented tonal step would silently not exist.

Two hooks, and neither is a control

The media query follows the operating system unless the user has chosen; the attribute is that choice. Theme toggle deliberately stamps nothing before paint, because that is a head concern the app has to add for itself.

An app that ships light-only should say so with color-scheme: light rather than by shipping half a palette. Half a dark theme is a bug report.

Navigate

Esc