Colour
StableTwelve semantic greys that switch with the theme, and six fixed brand values for the logo and brand panels.
Components only use the semantic names (text-fg, bg-raised, border-line), so they are right in both themes without a single dark: class. Each name is a CSS variable that a .dark ancestor redefines. The brand palette (ink, paper, graphite, board, fog, stone) never changes and is for the logo, brand panels and print.
Semantic tokens
import { useEffect, useRef, useState } from 'react'import { cx } from '@rchursin/components/cx'
/** * Every semantic colour, measured live in this browser: the value the swatch really paints and its * contrast against the surface. Switch the site theme and the numbers follow. */type Token = { name: string; swatch: string; role: string; text?: boolean; on?: 'inverse' }
// Utilities are written out in full so Tailwind can find them.const groups: { title: string; tokens: Token[] }[] = [ { title: 'Text', tokens: [ { name: 'fg', swatch: 'bg-fg', role: 'Headings, body text, icons', text: true }, { name: 'fg-2', swatch: 'bg-fg-2', role: 'Descriptions, inactive tabs', text: true }, { name: 'fg-3', swatch: 'bg-fg-3', role: 'Meta: hints, dates, mono labels', text: true }, ], }, {Usage
import '@rchursin/product.css' // brand tokens, fonts, the finishing layer and the app tokens<article className="rounded-2xl bg-raised p-5 ring-1 ring-line"> <h3 className="text-fg">Weekly summary</h3> <p className="text-fg-3">Updated 2 hours ago</p></article>Examples
Brand palette
Six fixed values. They do not change with the theme: use them where a colour must stay put, like the logo on a photo.
/** * The brand palette: six fixed values that never change with the theme. Use them for the logo, * brand panels and print; use the semantic tokens for interface. */const palette = [ { name: 'ink', hex: '#0a0a0a', swatch: 'bg-ink', use: 'The black: logo on light, dark grounds' }, { name: 'paper', hex: '#ffffff', swatch: 'bg-paper', use: 'The white: logo on dark, light grounds' }, { name: 'graphite', hex: '#171716', swatch: 'bg-graphite', use: 'Raised dark surfaces' }, { name: 'board', hex: '#1f1f1e', swatch: 'bg-board', use: 'Presentation backgrounds' }, { name: 'fog', hex: '#a3a3a3', swatch: 'bg-fog', use: 'Secondary text on dark: 7.9:1 on ink' }, { name: 'stone', hex: '#737373', swatch: 'bg-stone', use: 'Secondary text on light: 4.7:1 on paper' },]
export default function ColorPalette() { return ( <div className="@container w-full"> <ul className="grid grid-cols-2 gap-x-4 gap-y-6 @lg:grid-cols-3 @3xl:grid-cols-6"> {palette.map((c) => ( <li key={c.name} className="flex min-w-0 flex-col gap-2.5"> <span aria-hidden="true" className={`h-16 rounded-xl ring-1 ring-line-strong ring-inset ${c.swatch}`} /> <span className="flex items-baseline justify-between gap-2"> <code className="font-mono text-[12.5px] font-medium text-fg">{c.name}</code> <span className="font-mono text-[11px] text-fg-3 uppercase tabular-nums">{c.hex}</span> </span> <span className="text-[12.5px]/5 text-pretty text-fg-3">{c.use}</span> </li> ))} </ul> </div> )}Tokens at work
A small list built only from tokens. Hover a row, or click one to open it: the open row gets the selected tint and a bar, so the state never rests on a tint alone.
import { useState } from 'react'import { cx } from '@rchursin/components/cx'import { SampleBadge } from '@rchursin/product/shell/layout'
/** * One small list built only from semantic tokens, so it is right in both themes without a single dark: class. * The open row has the selected tint and a 2px bar in fg: a shape, so the state never rests on a tint alone. */const rows = [ { id: 'rc-41', title: 'Ship the month view', note: 'Calendar · due Friday' }, { id: 'rc-38', title: 'Write docs for the segmented control', note: 'Library · 2 comments' }, { id: 'rc-35', title: 'Check focus rings on chips', note: 'Accessibility · no date' },]
export default function ColorInUse() { const [open, setOpen] = useState('rc-41') return ( <div className="@container w-full max-w-3xl"> <div className="grid items-start gap-8 @2xl:grid-cols-[minmax(0,1fr)_14rem]"> <section aria-labelledby="inbox-title" className="overflow-hidden rounded-2xl bg-surface ring-1 ring-line">API
CSS
| Class or variable | What it does |
|---|---|
text-fg · --rc-fg | Main text and icons. #0a0a0a light, #f5f5f5 dark: 19.8:1 and 17.8:1 on surface. |
text-fg-2 · --rc-fg-2 | Secondary text: descriptions, inactive tabs. #404040 / #d0d0d0: 10.4:1 and 12.6:1. |
text-fg-3 · --rc-fg-3 | Meta text: hints, dates, mono labels. #6b6b6b / #9c9c9c: 5.3:1 and 7.1:1 on surface, 4.9:1 on light sunken. The lightest grey allowed for text. |
bg-surface · --rc-surface | The page. #ffffff / #0d0d0d. The body paints it. |
bg-raised · --rc-raised | Cards, menus, dialogs. #ffffff / #161616: in dark mode a raised surface is lighter, not shadowed. |
bg-sunken · --rc-sunken | Wells: code blocks, read-only fields, footers of panels. #f6f6f6 / #080808. |
border-line · --rc-line | Hairlines between rows and around panels. Black or white at 8%. |
ring-line-strong · --rc-line-strong | Edges that must show: outline chips, control borders, dashed slots. Black or white at 16%. |
bg-hover · --rc-hover | A row or item under the pointer. Black at 4%, white at 5%. |
bg-selected · --rc-selected | The current row, open item or active nav link. Black at 6.5%, white at 8.5%; fg-3 still reads 4.6:1 on it. |
bg-inverse · --rc-inverse | Tooltips, solid badges and counts. The opposite of the page: #0a0a0a / #f5f5f5. |
text-on-inverse · --rc-on-inverse | Text on inverse. 19.0:1 light, 18.2:1 dark. |
--rc-backdrop | The dim layer behind dialogs (used by rc-dialog::backdrop). Ink at 28% light, black at 60% dark. |
ink · paper · graphite · board · fog · stone | Brand palette, fixed: #0a0a0a, #ffffff, #171716, #1f1f1e, #a3a3a3 (7.9:1 on ink), #737373 (4.7:1 on paper). Utilities: bg-ink, text-paper, text-fog … |
Accessibility
- Every text token clears WCAG AA (4.5:1) on surface, raised and sunken in both themes; fg and fg-2 clear AAA. The first example measures this live.
- Lines, hover and selected are tints, not signals: the kit pairs them with a shape, an icon or words, and its selectable rows also set aria-current or aria-selected.
- Opacity variants (text-fg/60) lower contrast. Check the result, or use fg-2 and fg-3, which are already tuned.
Guidelines
Do
- Use the semantic names in components: text-fg, bg-raised, border-line.
- Use fg-3 for the quietest text; it is the lightest grey that still passes.
- Show state with shape, weight, icons or words, and use the tints only to reinforce it.
Don’t
- Don’t use raw greys (text-neutral-500, #888) in components; they will not flip with the theme.
- Don’t reach for the brand palette in interface code; ink and paper do not switch.
- Don’t add colour to signal status. The brand is black and white.
In use
Used on this site
Related
- Dark modeOne class flips every token: on <html> for the whole page, or on any wrapper for one part of it.
- Fills & patternsSolid, hatched, outline and dashed: the second “colour” of a black-and-white interface, plus the dot-grid canvas.
- TypographyGeist for the interface and Geist Mono for labels, code and the wordmark, in the sizes the site and the kit really use.
- Cards & elevationCards on one four-step elevation scale: lux-card, a lower lux-card-flat, lux-lift on hover and lux-float for overlays.
Source
The component’s own files, exactly as they run on this site.
/* RChursin_ product kit: app-level tokens and details on top of brand.css, effects.css and luxe.css. Import through src/product.css (which also pulls in the brand layer).
Semantic colours become Tailwind utilities: text-fg, text-fg-2, text-fg-3, bg-surface, bg-raised, bg-sunken, border-line, ring-line-strong, bg-hover, bg-selected … They switch with the .dark class, so components never need dark: variants for these.*/
@theme inline { --color-fg: var(--rc-fg); --color-fg-2: var(--rc-fg-2); --color-fg-3: var(--rc-fg-3); --color-surface: var(--rc-surface); --color-raised: var(--rc-raised); --color-sunken: var(--rc-sunken); --color-line: var(--rc-line); --color-line-strong: var(--rc-line-strong); --color-hover: var(--rc-hover); --color-selected: var(--rc-selected);