Stroke-To-Fill Hover Rows
A list of large type where every row is an outline with a ghost of fill, and the hovered row materialises: the stroke drops to zero width, the fill goes to full `currentColor`, and the row steps 12px to the right while its siblings recede. The row does not light up, it becomes solid. Everything is CSS, so nothing but the compositor runs on hover.
Stroke-To-Fill Hover Rows
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/stroke-to-fill-hover.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| items | StrokeToFillItem[] | — | Rows. Each has a label, an optional meta node for right-hand metadata, and an optional href. |
| strokeWidth | number | = 1 | Outline thickness in px at rest. |
| ghost | number | = 16 | Resting fill as a percentage of currentColor. |
| nudge | number | = 12 | Pixels the hovered row steps right. |
| className | string | — | Merged onto the <ul> via cn. |
| onSelect | (item: StrokeToFillItem, index: number) => void | — | Called when a row without an href is activated. |
How it works
The technique in order, not the code in order.
Two properties carry the effect, and they are separate on purpose:
-webkit-text-stroke-widthgoes 1px to 0, and-webkit-text-fill-colorgoes from a ghost ofcurrentColortocurrentColoritself.-webkit-text-fill-coloroverridescolorfor the glyph interior only, which is what lets the outline stay atcurrentColorwhile the inside is nearly transparent. Doing this withcoloralone is impossible —coloris also whatcurrentColorresolves to, so fading the fill fades the stroke with it and the row just dims.The ghost is
color-mix(in oklch, currentColor var(--kx-sf-ghost, 16%), transparent)rather than a literalrgba. It inherits the surrounding text colour, so one component is correct on a dark page and a light one, and mixing in oklch keeps the 16% step perceptually even instead of clumping toward black the way sRGB alpha does.The three tunables are custom properties set on the
<ul>from props (--kx-sf-stroke,--kx-sf-ghost,--kx-sf-nudge), so per-instance configuration needs no extra class and no extra rule.Sibling dimming is scoped to the list's own hover:
.kinetic-sf-list:hover .kinetic-sf-row:not(:hover) { opacity: 0.45 }. Scoping to the list is what makes the resting state "all rows equal" rather than "all rows dimmed".Every hover rule is duplicated for
:focus-visible. Each row is a real<a>or<button>inside an<li>, so keyboard traversal produces the same state change — without it a keyboard user tabbing ten identical outlines has no idea where they are.The stylesheet is emitted once through React 19's
<style href precedence>hoisting, so any number of lists on a page share one rule set.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| STROKE_WIDTH | = 1 | Pixels of outline at rest. At the 28-72px type this component targets, 1px is a hairline that still reads as a complete letterform; 2px starts to close up counters in tighter faces and the row looks bold rather than outlined. |
| GHOST | = 16 | Percent of currentColor mixed into the resting fill. At 0 the glyph interiors are fully transparent and the type dissolves into whatever is behind it; past ~30% the resting and hovered states are too close for the transition to register. |
| NUDGE | = 12 | Pixels the hovered row steps right. Enough to be unmistakable next to a static neighbour; past ~24px the row visibly leaves the left margin of the list and the alignment looks broken. |
| transition duration / curve | = 380ms cubic-bezier(0.16, 1, 0.3, 1) | A strong ease-out. The row arrives quickly and settles, which matches the pointer having already stopped. A symmetric ease at this duration lags the cursor noticeably on a fast pass down a ten-row list. |
| sibling opacity | = 0.45 | Enough separation that the hovered row is unambiguous, while the others stay legible — dimming to 0.2 turns a browsable index into a spotlight and hides the thing the user is scanning for. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The stroke-to-fill swap is a state change, not motion, so it is kept in full — the hovered row still fills solid and its siblings still recede. Only the 12px lateral travel is removed, via transform: none in a prefers-reduced-motion: reduce block. The rows are never left unreadable.
Nothing to clean up: no effect, no listener, no timer. Unmounting removes the list; React's <style precedence> hoisting owns the shared rule set.
Hover a row: the outline thins to nothing as the glyph interiors fill to full colour, and the row slides 12px right while the others fade back. Tab through with a keyboard and the same state appears on the focused row. Flip the page between light and dark themes and the resting ghost fill is still faintly visible in both, without editing the component. With reduced motion enabled the fill still happens and the row does not move.
Pitfalls
- Trying to build this with
coloralone. Fading the fill fades the stroke with it becausecurrentColorresolves to the same value, so the row dims instead of filling. - Transitioning the
-webkit-text-strokeshorthand. Shorthands are not independently interpolable here; transition-webkit-text-stroke-widthexplicitly or the stroke snaps off in one frame while the fill glides. - Hardcoding the resting fill as
rgba(255,255,255,0.16). The component is then wrong the moment it lands on a light background, and wrong again in an inverted section of the same page. - Mixing the ghost in sRGB. The intermediate steps clump toward black, so the resting fill on a mid-tone accent looks muddy rather than faint.
- Dimming siblings without scoping to
.kinetic-sf-list:hover. Every row is then permanently at 0.45 and the "recede" has nothing to recede from. - Styling
:hoverand forgetting:focus-visible. Keyboard users get ten identical outlines and no indication of the current row, which is a genuine accessibility failure and not just a polish gap. - Animating
leftormargin-leftfor the nudge. Both trigger layout on every row of a large-type index;translate3dstays on the compositor.
Notes
- No text splitting and no motion library: this is a state change on a whole row, so imposing a splitter or GSAP on the consumer for a CSS-cost effect would be pure overhead.
-webkit-text-strokeis unprefixed-less by history but supported in every current engine including Firefox and Safari. There is no standards-track alternative that composites the same way.- Rows render as
<a>whenhrefis given and as<button>otherwise, so the list is keyboard-traversable either way without atabIndexhack.