Outline Text Effect
Original · freeoutline-to-fill text
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/txfx-outline.json"use client";
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Trigger = "hover" | "click" | "auto";
type Direction = "left" | "up" | "center";
type FillStyle = "solid" | "gradient" | "shimmer";
const cx = (...c: Array<string | false | null | undefined>): string =>
c.filter(Boolean).join(" ");
const HIDDEN: Record<Direction, string> = {
left: "inset(0% 100% 0% 0%)",
up: "inset(100% 0% 0% 0%)",
center: "inset(0% 50% 0% 50%)",
};
const SHOWN = "inset(0% 0% 0% 0%)";
const OUTLINE_CSS = `
@keyframes txfxol-shimmer { 0% { background-position: 0% 0; } 100% { background-position: -200% 0; } }
.txfxol-outline {
color: transparent;
-webkit-text-stroke-width: var(--txfxol-sw, 2px);
-webkit-text-stroke-color: var(--txfxol-stroke, #475569);
paint-order: stroke fill;
}
:root { --txfxol-stroke: #475569; }
@media (prefers-color-scheme: dark) { :root { --txfxol-stroke: #94a3b8; } }
.dark .txfxol-outline { -webkit-text-stroke-color: #94a3b8; }
@media (prefers-reduced-motion: reduce) {
.txfxol-shimmer-fill { animation: none !important; }
}
`;
const TRIGGER_OPTS: { value: Trigger; label: string }[] = [
{ value: "hover", label: "Hover" },
{ value: "click", label: "Click" },
{ value: "auto", label: "Auto" },
];
const DIRECTION_OPTS: { value: Direction; label: string }[] = [
{ value: "left", label: "Wipe →" },
{ value: "up", label: "Rise ↑" },
{ value: "center", label: "Split ↔" },
];
const FILL_OPTS: { value: FillStyle; label: string }[] = [
{ value: "solid", label: "Solid" },
{ value: "gradient", label: "Gradient" },
{ value: "shimmer", label: "Shimmer" },
];
const HINTS: Record<Trigger, string> = {
hover: "Hover the specimen with a pointer, or tab into it, to paint each glyph.",
click: "Use the button below (or tap the specimen) to fill and reset the letters.",
auto: "The specimen paints and empties itself on a gentle loop.",
};
function PlayIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true" focusable="false">
<path d="M7 5v10l8-5-8-5Z" fill="currentColor" />
</svg>
);
}
function ResetIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true" focusable="false">
<path
d="M10 6a5 5 0 1 0 4.9 6"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M11 3 8 6l3 3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ReplayIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true" focusable="false">
<path
d="M15.5 10a5.5 5.5 0 1 1-1.7-3.98"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M15 3v4h-4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function TargetIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true" focusable="false">
<circle
cx="10"
cy="10"
r="5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M10 1v3M10 16v3M1 10h3M16 10h3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
interface SegmentedProps<T extends string> {
label: string;
options: { value: T; label: string }[];
value: T;
onChange: (value: T) => void;
}
function Segmented<T extends string>({
label,
options,
value,
onChange,
}: SegmentedProps<T>) {
const labelId = useId();
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const focusAt = (i: number) => {
const n = (i + options.length) % options.length;
onChange(options[n].value);
refs.current[n]?.focus();
};
const onKey = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
focusAt(i + 1);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
focusAt(i - 1);
break;
case "Home":
e.preventDefault();
focusAt(0);
break;
case "End":
e.preventDefault();
focusAt(options.length - 1);
break;
default:
break;
}
};
return (
<div className="flex flex-col gap-2">
<span
id={labelId}
className="text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400"
>
{label}
</span>
<div
role="radiogroup"
aria-labelledby={labelId}
className="inline-flex gap-1 rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800/70"
>
{options.map((o, i) => {
const active = o.value === value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
ref={(el) => {
refs.current[i] = el;
}}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cx(
"rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900",
active
? "bg-white text-indigo-600 shadow-sm dark:bg-slate-950 dark:text-indigo-300"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
)}
>
{o.label}
</button>
);
})}
</div>
</div>
);
}
interface GlyphsProps {
text: string;
revealed: boolean;
trigger: Trigger;
direction: Direction;
fillStyle: FillStyle;
reduced: boolean;
replayKey: number;
delay: number;
}
function Glyphs({
text,
revealed,
trigger,
direction,
fillStyle,
reduced,
replayKey,
delay,
}: GlyphsProps) {
const hidden = HIDDEN[direction];
const fillClass =
fillStyle === "solid"
? "text-indigo-600 dark:text-indigo-400"
: fillStyle === "gradient"
? "bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-400 bg-clip-text text-transparent"
: "txfxol-shimmer-fill";
const fillInline: CSSProperties =
fillStyle === "shimmer"
? {
backgroundImage:
"linear-gradient(110deg,#6366f1 0%,#8b5cf6 30%,#38bdf8 50%,#8b5cf6 70%,#6366f1 100%)",
backgroundSize: "200% 100%",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
animation: reduced ? "none" : "txfxol-shimmer 3.2s linear infinite",
}
: {};
const animateClip = reduced
? SHOWN
: trigger === "auto"
? [hidden, SHOWN, SHOWN, hidden]
: revealed
? SHOWN
: hidden;
return (
<span className="relative inline-block whitespace-nowrap leading-none">
<span className="txfxol-outline">{text}</span>
<motion.span
key={`${trigger}-${direction}-${fillStyle}-${replayKey}`}
aria-hidden="true"
className={cx("pointer-events-none absolute inset-0 select-none", fillClass)}
style={fillInline}
initial={reduced ? false : { clipPath: hidden }}
animate={{ clipPath: animateClip }}
transition={
reduced
? { duration: 0 }
: trigger === "auto"
? {
duration: 3.6,
times: [0, 0.4, 0.72, 1],
repeat: Infinity,
ease: "easeInOut",
delay,
}
: { duration: 0.8, ease: [0.22, 1, 0.36, 1], delay }
}
>
{text}
</motion.span>
</span>
);
}
export default function TxfxOutline() {
const reduced = useReducedMotion() ?? false;
const [trigger, setTrigger] = useState<Trigger>("hover");
const [direction, setDirection] = useState<Direction>("left");
const [fillStyle, setFillStyle] = useState<FillStyle>("gradient");
const [stroke, setStroke] = useState<number>(2);
const [hovered, setHovered] = useState(false);
const [clicked, setClicked] = useState(false);
const [replayKey, setReplayKey] = useState(0);
const stageRef = useRef<HTMLDivElement>(null);
const strokeId = useId();
useEffect(() => {
setHovered(false);
setClicked(false);
}, [trigger]);
const revealed =
trigger === "hover" ? hovered : trigger === "click" ? clicked : false;
const primary =
trigger === "hover"
? {
label: "Focus the specimen",
onClick: () => stageRef.current?.focus(),
Icon: TargetIcon,
}
: trigger === "click"
? {
label: clicked ? "Reset to outline" : "Fill the letters",
onClick: () => setClicked((c) => !c),
Icon: clicked ? ResetIcon : PlayIcon,
}
: {
label: "Replay the loop",
onClick: () => setReplayKey((k) => k + 1),
Icon: ReplayIcon,
};
const PrimaryIcon = primary.Icon;
const stageStyle = { "--txfxol-sw": `${stroke}px` } as CSSProperties;
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-white to-slate-50 py-20 dark:from-slate-950 dark:to-slate-900 sm:py-28 lg:py-32">
<style>{OUTLINE_CSS}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
<div className="absolute -bottom-24 right-1/4 h-72 w-72 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-600/20" />
</div>
<div className="relative mx-auto max-w-5xl px-6">
<p className="text-center text-xs font-semibold uppercase tracking-[0.28em] text-indigo-600 dark:text-indigo-400">
Kern & Co · Variable Type Foundry
</p>
<div
ref={stageRef}
tabIndex={trigger === "hover" ? 0 : -1}
aria-label={
trigger === "hover"
? "Type specimen — fills while hovered or focused"
: undefined
}
onMouseEnter={() => {
if (trigger === "hover") setHovered(true);
}}
onMouseLeave={() => {
if (trigger === "hover") setHovered(false);
}}
onFocus={() => {
if (trigger === "hover") setHovered(true);
}}
onBlur={() => {
if (trigger === "hover") setHovered(false);
}}
onClick={() => {
if (trigger === "click") setClicked((c) => !c);
}}
className={cx(
"mt-8 rounded-3xl border border-slate-200 bg-white/70 px-6 py-14 shadow-sm backdrop-blur",
"dark:border-slate-800 dark:bg-slate-900/50 sm:py-20",
trigger !== "auto" && "cursor-pointer",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
)}
>
<h2 className="text-center font-serif font-bold uppercase leading-none tracking-tight text-slate-900 dark:text-white">
<span className="block text-5xl sm:text-7xl md:text-8xl">
<Glyphs
text="Meridian"
revealed={revealed}
trigger={trigger}
direction={direction}
fillStyle={fillStyle}
reduced={reduced}
replayKey={replayKey}
delay={0}
/>
</span>
<span className="mt-1 block text-5xl sm:text-7xl md:text-8xl">
<Glyphs
text="Display"
revealed={revealed}
trigger={trigger}
direction={direction}
fillStyle={fillStyle}
reduced={reduced}
replayKey={replayKey}
delay={0.12}
/>
</span>
</h2>
</div>
<p className="mx-auto mt-8 max-w-2xl text-center text-base leading-relaxed text-slate-600 dark:text-slate-300">
A high-contrast display serif drawn for headlines that have to earn
their size. Preview the outline-to-fill reveal below — change how it
triggers, which way it paints, and how each glyph gets its color.
</p>
<p
aria-live="polite"
className="mx-auto mt-3 max-w-2xl text-center text-sm text-slate-500 dark:text-slate-400"
>
{HINTS[trigger]}
</p>
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-slate-800 dark:bg-slate-900/60 sm:p-6">
<div className="flex flex-wrap items-end justify-center gap-x-8 gap-y-6">
<Segmented
label="Trigger"
options={TRIGGER_OPTS}
value={trigger}
onChange={setTrigger}
/>
<Segmented
label="Reveal"
options={DIRECTION_OPTS}
value={direction}
onChange={setDirection}
/>
<Segmented
label="Fill"
options={FILL_OPTS}
value={fillStyle}
onChange={setFillStyle}
/>
<div className="flex flex-col gap-2">
<label
htmlFor={strokeId}
className="text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400"
>
Stroke · {stroke.toFixed(1)}px
</label>
<input
id={strokeId}
type="range"
min={1}
max={4}
step={0.5}
value={stroke}
aria-valuetext={`${stroke.toFixed(1)} pixels`}
onChange={(e) => setStroke(Number(e.target.value))}
className="h-9 w-40 cursor-pointer accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
/>
</div>
</div>
<div className="mt-6 flex justify-center border-t border-slate-200 pt-6 dark:border-slate-800">
<button
type="button"
onClick={primary.onClick}
className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 active:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900"
>
<PrimaryIcon />
{primary.label}
</button>
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Aurora Gradient Headline
OriginalAn animated headline whose living multi-colour gradient drifts across every letter while the words blur and stagger into place on scroll.

Character Reveal Heading
OriginalA cinematic heading that blurs and lifts in one character at a time with a staggered, scroll-triggered reveal.

Shimmer Sweep Headline
OriginalA headline with a polished light sheen that glides endlessly across the letters and races faster on hover.

Rotating Words Headline
OriginalA headline whose final word flips through a curated set with a blurred vertical roll while the rest of the line stays perfectly still.

Gradient Animate Text Effect
Originalanimated gradient text

Typing Text Effect
Originaltypewriter text with caret

Shiny Text Effect
Originalshiny sweep text

Glitch Text Effect
Originalglitch text effect

Wave Text Effect
Originalwavy letter animation

Reveal Mask Text Effect
Originalmask reveal heading

3D Text Effect
Originallayered 3D text

Scramble Text Effect
Originalscramble/decode text

