Outline Fill Text
Stroked outline type whose solid copy is wiped in by an animated inset — on scroll, on hover, or on mount.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/outline-fill-text.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "OutlineFillText" component (no animation library).
Contract
- Export a forwardRef component extending Omit<React.HTMLAttributes<HTMLElement>, "children">,
spreading the rest onto the root.
- Props: text (string, the whole line), as ("span" | "div" | "p" | "h1".."h4", default "span"),
trigger ("scroll" | "hover" | "always", default "scroll"), direction ("right" | "left" |
"down" | "up", default "right" — the edge the fill travels toward), duration (ms, default 700),
strokeWidth (number = px, or any CSS length string such as "0.018em", default 1.5),
strokeColor and fillColor (design-token names: foreground / muted-foreground / primary /
chart-1..chart-5, defaults "muted-foreground" and "primary").
Behavior
- Two layers over one box: the root is relative, inline-block and width:fit-content (fit-content
matters — blockified into a flex or grid track the root would stretch and the sweep would spend
its tail crossing empty space); layer 1 is the real text with transparent color and a text
stroke; layer 2 is an absolutely inset-0 duplicate painted solid.
Because the duplicate is stretched over the same box, it wraps line-for-line with the original —
no width measurement, no per-character splitting, multi-line headlines just work. Give the
duplicate `padding: inherit` as well: inset-0 resolves against the root's padding box, so without
it any padding the consumer adds would shift the solid copy off the outline by that much.
- The sweep is a clip-path transition on layer 2 only: it rests at a collapsed four-value inset
(inset(0% 100% 0% 0%) for direction "right", 0% 0% 0% 100% for "left", 0% 0% 100% 0% for "down",
100% 0% 0% 0% for "up") and transitions to inset(0% 0% 0% 0%) over `duration` with an
ease-out curve. Both endpoints are percentage insets so the interpolation is clean.
- trigger "scroll": two IntersectionObservers on the root, both disconnected on the first fill and
on unmount. The trigger one takes threshold 0 and rootMargin "0px 0px -20% 0px" (a ratio threshold
can never fire for a headline taller than the viewport) and fills on the first intersecting entry.
The second is a backstop against the real viewport (threshold 0, no margin), because that raised
bottom edge can sit past the end of a short page: a line parked in the bottom fifth of a document
with no scroll left never intersects the adjusted root, the observer has no further crossing to
report, and the headline would stay a bare outline for the whole session. So on an intersecting
backstop entry, compare entry.boundingClientRect.top against the raised edge (innerHeight * 0.8)
and against the scroll the document has left (scrollHeight - clientHeight - scrollTop), and fill
straight away when the line can never reach that edge.
- trigger "always": set the filled flag inside a single requestAnimationFrame so the bare outline
gets one painted frame to transition from; cancel the frame on unmount. That same frame is the
fallback when the scroll trigger has nothing to observe with (no IntersectionObserver in the
environment) — a headline stranded as an empty outline is the one outcome that is not allowed.
- trigger "hover": fill on pointerenter, unfill on pointerleave, and do exactly the same on focus
and blur so keyboard users get the state a mouse user sees. Only in this mode does the root take
tabIndex 0 plus a focus-visible ring. On a coarse pointer — matchMedia("(pointer: coarse)") —
hover silently degrades to the scroll trigger, because a touch device would otherwise leave the
headline as an empty outline forever. Nothing binds touch events, so page scrolling is untouched.
- Read prefers-reduced-motion and pointer coarseness with useSyncExternalStore over matchMedia
(server snapshot false, change listeners removed on unmount) so a mid-session OS change is
respected. Under reduced motion no observer and no frame are created at all — a CSS media rule
pins the clip open, so the line is simply already filled.
- Changing `text` or the effective trigger re-arms the sweep during render (compare a previous key,
no effect), so a swapped headline animates again instead of appearing pre-filled.
Rendering & styling
- Semantic tokens only: stroke and fill colours resolve from a token map to var(--foreground) /
var(--muted-foreground) / var(--primary) / var(--chart-1..5). No hex, no palette classes. The
component sets no font-size or weight — it inherits typography from className.
- Ship the CSS in one React 19 hoisted <style href precedence> sheet (dedupes by href): the
text-stroke longhands, the clip transition, and three safety nets after them so they win at
equal specificity — prefers-reduced-motion (clip open, transition none), @supports not
(-webkit-text-stroke-width) (fall back to inherited solid colour, hide the duplicate), and
forced-colors: active (CanvasText, zero stroke, duplicate hidden).
- Accessibility: only the outline layer is exposed — the solid duplicate is aria-hidden,
pointer-events-none and select-none, so screen readers read the line once and a drag-select
copies it once. Nothing announces the fill: it is decoration over text that never changes, so a
live region would be noise. Merge className with cn() and pass every extra prop through.
Customization levers
- Trigger: "scroll" for landing sections, "hover" for interactive nav/menu type, "always" for an
above-the-fold hero that must not wait for a scroll.
- Pace: duration 400–600ms feels like a UI response (hover), 900–1600ms feels cinematic (scroll);
swap the cubic-bezier for a linear curve to read as a mechanical wipe.
- Direction: "right"/"left" for a reading-order wipe, "down"/"up" to read as ink filling a mould.
- Weight: strokeWidth in em ("0.015em"–"0.03em") keeps the outline proportional across responsive
type steps; px pins it for a fixed size.
- Palette: strokeColor "muted-foreground" + fillColor "primary" is the safe default; chart-1..5
give per-section accents; strokeColor "foreground" with a thick stroke reads as poster type.
- Shape of the mask: replace the inset() endpoints with a circle() or polygon() for a diagonal or
radial reveal, or drive the clip from scroll progress instead of a boolean for a scrubbed fill.Concepts
- Two layers, one box — the outline is the real text and the solid copy is an
inset-0duplicate, so both wrap identically without measuring anything; the effect scales from a one-word badge to a three-line headline with no extra code. - Inset wipe instead of a gradient mask — the reveal is a
clip-path: inset()transition on the duplicate alone, four-value percentages at both ends, which keeps the interpolation cheap, compositor-friendly and directional. - Trigger normalisation —
scroll/hover/alwaysare collapsed into one effective mode before any listener is created; a coarse pointer rewriteshovertoscrollso a touch device never sits on an unfilled outline. - A backstop for the raised trigger line — the
-20%bottom margin can land past the end of a short page (a full-height hero, a modal, a landing page with no scrollbar), where the observer would never report a crossing; a second observer on the real viewport fills the line the moment it is on screen with too little scroll left to ever reach that margin, so no page layout can strand an empty outline. - Hover state that keyboards can reach — the hover mode is the only mode that takes a tab stop and a
focus-visiblering, and focus/blur drive the exact same fill as pointerenter/leave. - Reduced motion changes the resting state, not the content — the media rule pins the clip fully open, so the reduced-motion reading is solid text; the observer and the animation frame are never created, and flipping the OS setting mid-session re-runs the effect through a
matchMediasubscription. - Selectable, single-read text — the duplicate is
aria-hidden,select-noneandpointer-events-none, so assistive tech reads the line once and selecting it copies one clean string; forced-colors and no-text-stroke fallbacks drop the effect entirely rather than risk invisible type.
Variable Font Scroll
A headline whose variable-font axes interpolate with scroll position, thickening character by character as it crosses the viewport.
Text Clip Media
Display type used as a window onto media — a photo, a muted clip or a token gradient fills the glyphs, leans away from the pointer, and degrades to a solid token when the media never arrives.