Text

Outline Fill Text

Stroked outline type whose solid copy is wiped in by an animated inset — on scroll, on hover, or on mount.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * One hoisted sheet (React 19 <style href precedence>, dedupes by href) carries everything
 * Tailwind cannot express: the text-stroke longhands, the clip-path sweep, and the three
 * fallbacks that keep the words readable when the effect must not or cannot run — reduced
 * motion, forced colours, and browsers without text-stroke. The rule order matters: every
 * fallback sits after the base rules so it wins at equal specificity.
 */
const SHEET = `.zy-ofn-base,.zy-ofn-fill{-webkit-text-stroke-width:var(--ofn-stroke-w);-webkit-text-stroke-color:var(--ofn-stroke-c);paint-order:stroke fill}
.zy-ofn-base{color:transparent}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/outline-fill-text.json

Prompt

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-0 duplicate, 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 normalisationscroll / hover / always are collapsed into one effective mode before any listener is created; a coarse pointer rewrites hover to scroll so 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-visible ring, 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 matchMedia subscription.
  • Selectable, single-read text — the duplicate is aria-hidden, select-none and pointer-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.

On This Page