Docs
Color
Pick one color. Graphite UI builds the whole palette from it: every background, text color, border, button, and status color, in both light and dark, each pair checked to make sure the text on it is readable.
The important idea is this: you choose what a color is for, not what it is. You say “this is the page background” or “this is a button”, and the system decides the actual value. So when the source color changes, everything updates together and stays readable, and nobody has to go and edit components.
Every value on this page is live. It reflects the source color set in the header right now. Change it and these tables re-resolve.
How color works
In shortOne color goes in. The system turns it into a set of named colors that each have a job, then wires those into the components you build with.
It happens in five steps. Each one takes the result of the step above and adds a decision.
- 1Your colorOne color. The system reads three things from it: which color it is, how intense, and how light.
- 2Raw shadesSeven strips of ten shades each, dark to light. Three built from your color, four fixed ones for danger, warning, success, and info.
- 3Named colorsTwenty-seven jobs, such as page background, body text, and button fill. Each gets a shade, and each is checked for readability.
- 4StatesWhat each of those looks like on hover, on click, when selected, when switched off, and when focused.
- 5Wired into components59 variables the component library reads, so changing a color actually repaints the product.
Why the math matters
The calculations run in OKLab, a way of describing color built to match how eyes actually work. Its useful property: equal steps in the numbers look like equal steps to a person. Going from tone 30 to 40 looks like the same size jump as going from 80 to 90.
Older color models (the ones behind HSL and hex codes) do not behave that way. That is why hand-picked palettes so often bunch up in the middle and flatten out at the light and dark ends.
Step 2: the ramps
A rampis one color laid out from dark to light, like a paint strip. The system keeps your color’s hue and varies how light it is, then repeats that at three levels of intensity:
Ramp | Intensity | What it is for |
|---|---|---|
Accent | Same as your color | Brand color, interactive elements, focus |
Neutral variant | Barely tinted | Secondary surfaces, borders, supporting text |
Neutral | Almost gray | Page backgrounds, primary surfaces, primary text |
Error / Warning / Success / Info | Tracks your color, within limits | Status and feedback. Hue is fixed per status, not derived |
The two neutrals are not quite gray. They keep a trace of your color, and that trace is what makes the finished interface look like one family instead of a brand color dropped onto a gray page. Each ramp gives you ten steps, labeled 900 (darkest) through 050 (lightest).
Two things worth knowing. The ten steps are just the ones shown. The system can produce any shade in between, and often does. Your exact color is kept. It appears on the accent ramp unchanged, taking the place of whichever step it sits closest to. Your brand color survives intact.
These raw colors have no meaning attached. Something like accent 40is just a shade. It is not “the button color.” Never reach for one directly in a design; use the named roles in the next section instead.
What you get out
Two files. CSS for engineers to point a build at, and JSON for tooling and design files. Alongside each color the export records where it came from, which ramp and which step, so “why is this color this color?” always has an answer.
Color roles
In shortEvery color in the interface has a job: page background, body text, button fill, error message. You pick the job; the system picks the value.
There are twenty-seven of these jobs, and each theme fills them in. One naming rule explains most of the list: a name starting with on is what goes on top of something else. onSurface is the text color for anything sitting on surface. That pairing is not a suggestion. The system measures it and guarantees it is readable.
The roles are grouped below by what they are for. You will use a handful of them constantly and most of the rest rarely.
Surfaces and backgrounds
Token | Purpose | Light | Dark |
|---|---|---|---|
background | The page itself | #f8f8fc | #121215 |
surface | Default container: cards, panels, sheets | #f8f8fc | #121215 |
surfaceVariant | Secondary surface: fields, hover fills, selected rows, tags | #dedcea | #2e2c37 |
background and surface resolve to the same value in both themes. Layers are separated by borders and surface variants, not by shading.
Content: text and icons
Token | Purpose | Light | Dark |
|---|---|---|---|
onBackground | Primary text and primary icons | #030305 | #dedde2 |
onSurface | Content on a default surface | #030305 | #dedde2 |
onSurfaceVariant | Secondary text and icons, supporting copy | #2e2c37 | #bdbcc9 |
Text hierarchy is two levels, not three. Icons follow text: primary icons take onBackground, secondary icons take onSurfaceVariant.
Borders
Token | Purpose | Light | Dark |
|---|---|---|---|
outline | All borders and dividers | #63626d | #807e8b |
One border token covers every border in the system. Interactive borders bind to primary instead. Checked against surface at 3:1, so borders are guaranteed perceivable.
Primary actions
Token | Purpose | Light | Dark |
|---|---|---|---|
primary | Primary buttons, links, interactive borders and icons | #4c2f93 | #beb1ff |
onPrimary | Content on a primary fill | #f8f7ff | #1a0044 |
primaryContainer | Low-emphasis accent fill: selected rows, tags | #ddd9ff | #340b74 |
onPrimaryContainer | Content on primaryContainer | #040015 | #ddd9ff |
The pairing rule is strict: onPrimary goes on primary, onPrimaryContainer goes on primaryContainer. Mixing them across containers breaks the contrast guarantee.
Status and feedback
Token | Purpose | Light | Dark |
|---|---|---|---|
danger | Errors, destructive actions, invalid input | #880c06 | #ffa192 |
onDanger | Content on a danger fill | #fff6f5 | #330000 |
dangerContainer | Low-emphasis danger fill: banners, rows | #ffd2ca | #5d0000 |
onDangerContainer | Content on dangerContainer | #0e0000 | #ffd2ca |
warning | Warnings, risky but permitted actions | #733300 | #ffa570 |
onWarning | Content on a warning fill | #fff7f2 | #280d00 |
warningContainer | Low-emphasis warning fill | #ffd4bc | #4c1f00 |
onWarningContainer | Content on warningContainer | #0a0100 | #ffd4bc |
success | Confirmation, completion, valid input | #00572f | #5adb91 |
onSuccess | Content on a success fill | #e9fff0 | #001c0c |
successContainer | Low-emphasis success fill | #7dfcb0 | #00381d |
onSuccessContainer | Content on successContainer | #000501 | #7dfcb0 |
info | Neutral information, tips, in-progress states | #00478a | #8fc1ff |
onInfo | Content on an info fill | #f4f9ff | #001632 |
infoContainer | Low-emphasis info fill | #c8e1ff | #002d5c |
onInfoContainer | Content on infoContainer | #00030e | #c8e1ff |
Hue is fixed per status so red still reads as danger whatever the source is; chroma tracks the source so statuses carry the same intensity as the rest of the system. Containers work exactly like primaryContainer: a low-emphasis fill for banners, rows, and tags.
Roles the system does not currently define
These are real gaps, not omissions from this page. If you need one of them, the answer is not to improvise a value.
Need | Current state | What to do today |
|---|---|---|
| Secondary actions | No secondary role is generated. | Build secondary buttons from outline (border) plus primary (label) on a transparent or surface fill. Keep it consistent across the product. |
| Links | No distinct link role. Links bind to primary. | Rely on underline plus primary for link affordance. Do not introduce a separate link color. |
| Overlay, scrim, elevation | No overlay or elevation token. The system has no shading-based elevation model. | Express elevation with outline and surfaceVariant. Modal scrims currently have no system value. |
Color hierarchy
In shortWhat makes one thing look like it sits on top of another. In this system that comes from borders and tinted areas, not from shadows or shading.
Because background and surface resolve to the same value, Graphite UI does not build depth by stacking progressively lighter or darker planes. Hierarchy comes from three other mechanisms: containment via outline, emphasis via surfaceVariant, and attention via the accent ramp.
Level | Token | Reads as |
|---|---|---|
| Page ground | background | The canvas |
| Container | surface + outline border | A defined region |
| Distinct region | surfaceVariant | A field, a hovered or grouped area |
| Selected / tagged | primaryContainer | Accented but not actionable |
| Primary action | primary | The thing to click |
| Primary content | onBackground / onSurface | What to read first |
| Secondary content | onSurfaceVariant | Supporting detail |
Two rules follow. Do not nest more than two surface levels. With one surface value and one variant, a third level has nothing to resolve to. primaryContainer is not a surface. It signals accent state, so a card using it reads as selected, not elevated.
Themes
In shortLight and dark are built at the same time from the same color. The names stay the same in both; only the values change.
You do not maintain two palettes. There is one set of names, and each theme fills them differently. onSurfacemeans “main text on a panel” in both, and it comes out near-black in light and near-white in dark.
Role | Light | Dark | Direction |
|---|---|---|---|
background | #f8f8fc | #121215 | Light ground → dark ground |
onBackground | #030305 | #dedde2 | Dark text → light text |
primary | #4c2f93 | #beb1ff | Dark accent → light accent |
onPrimary | #f8f7ff | #1a0044 | Light label → dark label |
outline | #63626d | #807e8b | Mid → slightly lighter mid |
The pattern: light and dark are near-mirror images across the ramp, with content and ground swapping ends and accent inverting with them. Hierarchy survives because the relationships survive: secondary content stays one perceptual step from primary content in both themes, even though the absolute values are opposite.
This is the concrete reason to use semantic tokens rather than values. A hex is correct in exactly one theme. primary is correct in both, on every source color a user picks, without a designer re-checking anything.
Contrast levels
Themes generate at one of two targets, applied to the whole theme rather than per token: AA (4.5:1 text, 3:1 non-text) or AAA (7:1 text, 3:1 non-text). The system is currently generating at AA.
Interaction states
In shortHow colors change when you hover over something, click it, tab to it, or when it is switched off.
A hovered button does not get a different color. It gets the same color, a few steps along its own ramp. That keeps it recognizably the same button and keeps the label readable.
Which direction depends on the theme: steps go darker in light mode and lighter in dark mode, always away from the background, so the button gets more prominent rather than fading into the page.
State | Derivation | Light | Dark |
|---|---|---|---|
| Default | #4c2f93 | #beb1ff | |
| Hover | #3d1b80 | #d0c9ff | |
| Pressed | #2f016d | #e4e0ff | |
| Selected | #3d1b80 | #d0c9ff | |
| Disabled | #dedde2 | #2e2d31 | |
| Disabled content | #47474b | #808084 | |
| Focus | #664db4 | #a08af7 |
- Hover and selected share a tone. They are visually identical by design; selection is distinguished by persistence and supporting affordances (a check, a bold label, a left border), not by color alone.
- Pressed is twice the hover shift, a clearly larger step, so the press reads as a distinct event rather than a stronger hover.
- Disabled leaves the accent ramp entirely. Both the fill and its content drop to neutral. This is the one state where an element deliberately loses its brand color. The pairing is intentionally low-contrast and is not contrast-checked. It is exempt under WCAG, and it must never be the only signal that a control is unavailable.
- Focus is a separate ring token, not a fill change. It does not replace the base color, so a focused button is still recognizably in its current state. Focus stacks with hover, pressed, and selected.
Danger, warning, and success are roles, not states. A field that fails validation takes dangerfor its border and message; it does not get a “danger hover.” Status roles and interaction states compose: a destructive button still hovers and presses along its own ramp.
Accessibility
In shortText has to stand out enough from whatever is behind it to be readable. The system checks this before it hands you a palette, rather than leaving you to test afterwards.
The measure is a contrast ratio, written like 4.5:1. The bigger the number, the easier the text is to read. Fourteen pairings are checked in both themes every time a palette is generated. If one ever fell short, the system nudges that color along its own ramp until it passes, keeping the hue and changing only how light it is.
Pairing | Target | Light | Dark |
|---|---|---|---|
onPrimary on primary | 4.5:1 | 9.20:1 pass | 9.70:1 pass |
onPrimaryContainer on primaryContainer | 4.5:1 | 15.20:1 pass | 10.70:1 pass |
onSurface on surface | 4.5:1 | 19.50:1 pass | 13.80:1 pass |
onSurfaceVariant on surfaceVariant | 4.5:1 | 10.10:1 pass | 7.30:1 pass |
onBackground on background | 4.5:1 | 19.50:1 pass | 13.80:1 pass |
outline on surface | 3:1 (UI) | 5.70:1 pass | 4.70:1 pass |
onDanger on danger | 4.5:1 | 9.40:1 pass | 9.40:1 pass |
onDangerContainer on dangerContainer | 4.5:1 | 15.00:1 pass | 10.50:1 pass |
onWarning on warning | 4.5:1 | 9.00:1 pass | 9.50:1 pass |
onWarningContainer on warningContainer | 4.5:1 | 15.10:1 pass | 10.20:1 pass |
onSuccess on success | 4.5:1 | 8.30:1 pass | 10.20:1 pass |
onSuccessContainer on successContainer | 4.5:1 | 16.10:1 pass | 10.40:1 pass |
onInfo on info | 4.5:1 | 8.70:1 pass | 9.70:1 pass |
onInfoContainer on infoContainer | 4.5:1 | 15.40:1 pass | 10.20:1 pass |
What the system does not guarantee
The guarantee covers defined pairings only. Everything outside that list is your responsibility.
onSurfaceVariantonsurfaceis not a checked pairing. Secondary text on a default surface is common and usually fine, but verify it.- Any cross-pairing you invent is unchecked and likely to fail:
onPrimaryContaineronsurface,primaryas body text,outlineas text. - Text over images, gradients, or generated patterns. No token can guarantee contrast against variable content; use a solid surface behind the text.
- Disabled states, which are exempt by design.
Focus indicators
Every interactive element needs a visible focus indicator. Use the focus ring token, which is generated per theme specifically to stay visible against both grounds. Never remove focus outlines, and never let the hover treatment double as the focus state; keyboard users never trigger hover.
Do not rely on color alone
This applies with unusual force here, for two system-specific reasons. The source color is user-chosen. You cannot assume the accent is blue, or warm, or dark, so any meaning attached to a specific hue will be wrong for some sources. And status hue can collide with the source: because status hues are fixed, a source color sitting on one of them resolves primary and that status to nearly the same value. A red brand makes primary and danger near-identical. Always pair color with a second signal: an icon, a label, a change of weight, a position, or a border.
Light and dark parity
Both themes are generated from the same ramps against the same targets, so a design that passes in one passes in the other. But check both before shipping. Anything built outside the token system (illustrations, screenshots, images with baked-in backgrounds, hard-coded hexes) will not follow the theme, and dark mode is where that shows up first.
Usage
Do
- Assign roles, not values. Reach for primary or onSurfaceVariant, never the hex they currently resolve to.
- Respect on pairings. onSurface belongs on surface; onPrimary belongs on primary.
- Use outline and surfaceVariant for depth, since the system has no elevation shading.
- Check the contrast table when you change the source color, especially at AAA.
- Pair color with a second signal for any state or status meaning, because status hue can collide with the source.
- Design in both themes before handing off.
Don’t
- Don’t apply raw hex values to components. A hex is a snapshot of one source color in one theme.
- Don’t reference primitives directly. accent 40 is a color without meaning.
- Don’t invent status colors from the accent or neutral ramps. Use the status roles.
- Don’t use primaryContainer as a general surface, because it makes everything look selected.
- Don’t nest three or more surface levels. There is no third value to resolve to.
- Don’t build hover or selected states by changing opacity. States are tone shifts on the ramp.
Tokens
In shortHow to find the right named color for what you are building.
Naming
Tokens use camelCase in JSON and JS (onSurfaceVariant), kebab-case in CSS with the --cts- prefix (--cts-on-surface-variant). Every token also emits its provenance: --cts-on-surface-variant-ramp and --cts-on-surface-variant-tone.
Choosing a token
Work down this order and stop at the first match.
- What is the element? A ground, a container, content, a border, or an action.
- Ground or container? background for the page, surface for a container, surfaceVariant for a field or distinct region.
- Content? onX, matching whatever it sits on.
- Border? outline, or primary and the focus ring if it indicates interaction.
- Action? primary + onPrimary for full emphasis, primaryContainer + onPrimaryContainer for low emphasis.
- Communicating status? danger, warning, success, or info, with their containers for low-emphasis fills.
- Interactive state? The state token for that role, never a manually adjusted value.
- No match? The role is missing from the system. Flag it rather than working around it.
Complete reference
Semantic roles: 27 per theme
primaryonPrimaryprimaryContaineronPrimaryContainersurfaceonSurfacesurfaceVariantonSurfaceVariantoutlinebackgroundonBackgrounddangeronDangerdangerContaineronDangerContainerwarningonWarningwarningContaineronWarningContainersuccessonSuccesssuccessContaineronSuccessContainerinfoonInfoinfoContaineronInfoContainer
Interaction states: 6 per theme
primaryprimary-hoverprimary-pressedprimary-selectedprimary-disabledfocus-ring
Primitives: 7 ramps × 10 stops
Exported for reference and tooling. Available to inspect and copy; not for direct use in designs.
Using tokens in Figma
The engine’s output is CSS and JSON. There is no published Figma library shipping with it today, so the Figma side is a workflow you set up rather than a fact of the system. The recommended approach:
- Create Figma variables that mirror the semantic role names exactly, so a design file and a code file name the same thing the same way.
- Use a variable mode per theme (Light / Dark) so a single design switches themes the way the product does.
- Import primitives as a separate, locked collection: visible for reference, hidden from the picker, so designers select roles rather than stops.
- Regenerate rather than edit. If the source color changes, re-import from the JSON export instead of hand-adjusting values, or the file will drift from the product.