Web InnoventixFreeCode

Outline Text Effect

Original · free

outline-to-fill text

byWeb InnoventixReact + Tailwind
txfxoutlinetext-effects
Open

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
txfx-outline.tsx
"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 &amp; 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

motion

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 quote

Similar components

Browse all →