When a UI works and still feels unfinished, assume the missing thing is a state, not a value. The overwhelmingly common cause is that some interactive element has a hover but no focus, a focus but no pressed state, no disabled treatment, or no answer for the second between the click and the response. Run the state sweep before you touch a single pixel value. Only once every interactive element has a complete row do you go looking for the optical nudges, the hairline greys, and the cursor that lies — and those are what this skill owns, because nobody else does.
This skill is the residue and nothing more. If you can name the dimension — shadow, radius, elevation, blur, gap, density, breakpoint — it is out by its own description, and surfaces owns the plane while spacing owns the gaps. Two more edges are worth naming, because they are the ones people reach for here first: the construction of a focus ring is a11y's, and the colour of a text selection is color's.
Find where the project keeps its interaction states before adding one. A :hover written at a call site in a codebase that has a Button component with variants is not polish, it is a fork. Look for the component layer, a data-state convention (Radix, Base UI), a cva/tv variant map, or a global stylesheet of element defaults, and put the missing state where its siblings already live. Corrections belong at the lowest layer that covers every instance.
Quick Reference
| You need | Open |
|---|---|
| The full sweep, in order, to run over a finished component or page | references/checklist.md — open it when the request is "go over this" and nothing specific is named. |
| The nudge for a specific shape, glyph, or label pair | references/optical-corrections.md — open it when something is mathematically centred and still looks wrong. |
Core Principles
- Fill the state matrix before anything else. Every interactive element owes five states — rest,
:hover,:focus-visible,:active,:disabled— plus a loading treatment wherever the action is async. Missing states are why a UI feels dead while every value in it is defensible. The exception is a link inside prose, which does not need an:activestate distinct from its hover.
-
Optical centring beats mathematical centring. A glyph's bounding box is not its visual mass, so maths puts it in the wrong place. A button with a trailing icon takes
2pxless padding on the icon side —pl-4 pr-3.5— and a play triangle shifts2pxtoward its point. The exception is a symmetric glyph in a symmetric box: measure first, because a nudge applied to something already centred is just as visible as one that was missing. -
Hairlines have three values, not one per component. Ship a three-step ladder —
#f2f2f2for the faintest internal rules,#edededfor the workhorse divider,#d9d9d9where a line must actually be seen, the values Benji Taylor's site runs on — and render them at0.5pxabove192dpithrough a--border-hairlinevariable that falls back to1px. Hand-picking a grey per divider is how a page ends up with nine of them. The exception is a hairline crossing a translucent surface, which needs alpha rather than hex or it will not composite. -
The cursor is a promise about what happens next.
pointeron everything clickable and on nothing else,texton the whole input wrapper rather than just the field,grabswapped tograbbingon pointerdown. During a drag or a resize, set the cursor onbodyfor the duration — otherwise it flickers back to the default every time the pointer outruns the handle, which reads as the drag breaking. The exception is a disabled control:cursor: not-allowedonly renders if the element still receives pointer events, so do not also setpointer-events: nonewhen you want the cursor or the explanatory tooltip. -
A scrim is a multi-stop eased gradient, never two stops. A two-stop linear fade bands visibly across its middle and leaves a soft hard line where there should be nothing at all; the fix is more stops following an easing curve, and Benji Taylor's site uses
12so content dissolves under the viewport edge. Build it asmask-imagerather than a gradient overlay wherever more than one background sits behind it. The exception is a flat modal overlay, which is one alpha over everything and not a fade at all. Never put a fade over scrollable content — it hides exactly what the reader is scrolling toward. -
Signal hover on the group, not only on the row. Lightening the hovered row competes with every other row's background; dimming the others to
opacity: .3reads instantly and needs no new colour, which is what Benji Taylor's site does on hover of a list. The exception is a list where rows carry their own status colour, since dimming would read as those items being disabled. -
Selection and caret are styled once, globally.
user-select: noneon control chrome — button labels, tab strips, table headers — because a blue smear across a control reads as malfunction; then audit what select-all actually grabs, which should be content and not interface.caret-coloris effectively the entire caret API, and replacing the caret means rebuilding text editing. The exception is a product aiming to feel installed, which inverts the default: selection off globally, re-enabled only on genuinely copyable content. -
The page animates in once, then holds still. A surface that keeps moving after arrival reads as unfinished no matter how good each individual animation is. The reference implementation is an
8pxtranslateYplus a fade over500ms, staggered50msper element with the total stagger capped at400ms, fired on first paint only — Benji Taylor's site. The exception is the app shell:500msdeliberately exceeds the300msproduct ceiling because it is a document entrance that fires once per session, and a view the user returns to forty times a day gets no entrance at all.
Smell / Fix
| Smell | Fix |
|---|---|
| "It works but feels dead" and nothing is nameable | Run the state matrix; find the missing rows |
| Icon looks off-centre in a button that is mathematically centred | 2px less padding on the icon side |
| Nine slightly different divider greys | Three-step hairline ladder through one variable |
| Dividers look chunky on retina | 0.5px above 192dpi, 1px fallback |
| Drag cursor flickers when the pointer moves fast | Set the cursor on body for the duration of the drag |
Disabled button shows no not-allowed cursor |
pointer-events: none is suppressing it — remove it |
| Visible band across a bottom fade | Two-stop gradient — go multi-stop and eased |
| Selecting text drags blue across the whole toolbar | user-select: none on control chrome |
| Everything on the page is subtly always moving | Entrance runs once, then the page is still |
cursor: pointer on a heading |
It is not clickable; remove it |
Output Format
Report a polish pass as the state matrix, one row per interactive element, so the gaps are the finding:
| Element | Rest | Hover | Focus-visible | Active | Disabled | Loading |
|---|---|---|---|---|---|---|
| Primary button | ✓ | ✓ | — | — | ✓ | — |
| Row action | ✓ | ✓ | ✓ | ✓ | n/a | n/a |
A dash is a defect, n/a is a decision you can defend. Follow the matrix with the optical and hairline corrections as ordinary Before/After rows.
Checklist
- Every interactive element has rest, hover, focus-visible, active, disabled, and loading where async
- Icons and asymmetric glyphs optically nudged, symmetric ones left alone
- One hairline ladder of three values, rendered at
0.5pxon high-DPI - Cursors match behaviour; drag cursors set on
bodyfor the drag's duration - Disabled controls still receive pointer events where a cursor or tooltip is wanted
- Fades are multi-stop and eased; no fade over a scroll area
- Hover states resolved on the group where a list is involved
-
user-select: noneon control chrome; select-all grabs content, not interface - The page settles after its entrance and then stops moving
- Nothing in this pass belongs to
surfaces,spacing,a11y, orcolor