Skip to content
goodtabs UI
BrowseBrowse components

Draw heading

A heading with a hand-placed underline or circle stroked in as it enters view.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/draw-heading.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/draw-heading

A pen stroke under one phrase, out and back, in the text colour.

Circled

A circle that overshoots its start, in the brand mark colour.

Usage

import { DrawHeading, DrawHeadingMark } from "@/components/ui/draw-heading"

<DrawHeading as="h1" mark="circle" className="text-7xl font-medium">
  Keep the <DrawHeadingMark>ten tabs</DrawHeadingMark> that matter
</DrawHeading>

Props

PropTypeDefaultDescription
as"h1" | "h2" | "h3" | "h4""h2"The element to render.
mark"underline" | "circle""underline"The shape drawn on DrawHeadingMark.
tone"foreground" | "brand""foreground"Stroke colour. Use brand once on a page.
delaynumber0Seconds to wait after the heading enters view.

Source

draw-heading/draw-heading.tsx
"use client";

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP, DrawSVGPlugin);

const MOTION = "(prefers-reduced-motion: no-preference)";

type Mark = "underline" | "circle";

/** One pen stroke per shape, absolute commands only so `fit` can scale it. Both overshoot like a hand does. */
const SHAPES: Record<
  Mark,
  { width: number; height: number; path: string; place: string; room?: string; seconds: number }
> = {
  underline: {
    width: 300,
    height: 24,
    path: "M3 12 C58 6 122 3 196 4 C232 4 268 5 297 8 C236 9 160 12 104 20",
    place: "-left-[0.03em] top-[94%] h-[0.24em] w-[calc(100%+0.06em)]",
    seconds: 0.9,
  },
  circle: {
    width: 300,
    height: 100,
    path: "M64 13 C118 2 226 1 274 24 C306 40 296 74 226 89 C150 104 42 98 13 68 C-12 40 42 12 118 7 C166 4 206 6 242 15",
    place: "-left-[0.34em] -top-[0.12em] h-[calc(100%+0.24em)] w-[calc(100%+0.68em)]",
    room: "mx-[0.26em]",
    seconds: 1.3,
  },
};

/** Multiply an absolute path's x and y by a scale, so a stretched shape keeps a round, even stroke. */
const fit = (path: string, x: number, y: number) => {
  let index = 0;
  return path.replace(/-?\d+(\.\d+)?/g, (n) => (Number(n) * (index++ % 2 ? y : x)).toFixed(2));
};

const MarkContext = React.createContext<{ mark: Mark; tone: "foreground" | "brand" }>({
  mark: "underline",
  tone: "foreground",
});

type DrawHeadingProps = React.ComponentProps<"h2"> & {
  /** The element to render. */
  as?: "h1" | "h2" | "h3" | "h4";
  /** The shape drawn on DrawHeadingMark. */
  mark?: Mark;
  /** Stroke colour. `brand` is the one accent, use it once on a page. */
  tone?: "foreground" | "brand";
  /** Seconds to wait after the heading enters view. */
  delay?: number;
};

/**
 * A heading with a hand-placed stroke on one phrase, drawn in on view. The text never moves or hides.
 * The stroke is in the markup, so with no script or under reduced motion it is simply there.
 */
function DrawHeading({
  as = "h2",
  mark = "underline",
  tone = "foreground",
  delay = 0,
  className,
  children,
  ...props
}: DrawHeadingProps) {
  const Tag = as as React.ElementType;
  const root = React.useRef<HTMLElement>(null);
  const context = React.useMemo(() => ({ mark, tone }), [mark, tone]);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(MOTION, (_, contextSafe) => {
        const element = root.current;
        if (!element || !contextSafe) return;
        const shape = SHAPES[mark];
        const pens = gsap.utils.toArray<SVGSVGElement>("[data-slot=draw-heading-stroke]");
        const paths = pens.map((pen) => pen.querySelector("path") as SVGPathElement);
        let alive = true;
        // A round cap paints a dot at zero length, so the stroke stays hidden until it starts.
        gsap.set(pens, { autoAlpha: 0 });

        // The still state stretches with CSS. To measure a true length, redraw it in CSS pixels first,
        // and put the stretch back afterwards so the mark keeps following the word.
        const toPixels = (pen: SVGSVGElement, path: SVGPathElement) => {
          const { width, height } = pen.getBoundingClientRect();
          pen.setAttribute("viewBox", `0 0 ${width} ${height}`);
          path.removeAttribute("vector-effect");
          path.setAttribute("d", fit(shape.path, width / shape.width, height / shape.height));
        };
        const toStretch = (pen: SVGSVGElement, path: SVGPathElement) => {
          pen.setAttribute("viewBox", `0 0 ${shape.width} ${shape.height}`);
          path.setAttribute("vector-effect", "non-scaling-stroke");
          path.setAttribute("d", shape.path);
          gsap.set(path, { clearProps: "strokeDasharray,strokeDashoffset" });
        };

        const draw = contextSafe(() => {
          if (!alive) return;
          pens.forEach((pen, i) => {
            toPixels(pen, paths[i]);
            gsap.fromTo(
              paths[i],
              { drawSVG: "0%" },
              {
                drawSVG: "100%",
                duration: shape.seconds,
                delay,
                ease: "power2.inOut",
                onStart: () => void gsap.set(pen, { autoAlpha: 1 }),
                onComplete: () => toStretch(pen, paths[i]),
              },
            );
          });
        }) as () => void;

        const watcher = new IntersectionObserver(
          (entries) => {
            if (!entries.some((e) => e.isIntersecting)) return;
            watcher.disconnect();
            void document.fonts.ready.then(draw);
          },
          { threshold: 0.4 },
        );
        watcher.observe(element);
        return () => {
          alive = false;
          watcher.disconnect();
          pens.forEach((pen, i) => toStretch(pen, paths[i]));
        };
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [mark, delay] },
  );

  return (
    <MarkContext.Provider value={context}>
      <Tag ref={root} data-slot="draw-heading" className={cn("m-0", className)} {...props}>
        {children}
      </Tag>
    </MarkContext.Provider>
  );
}

/** The phrase that gets the stroke. It stays on one line, so keep it to two or three short words. */
function DrawHeadingMark({ className, children, ...props }: React.ComponentProps<"span">) {
  const { mark, tone } = React.useContext(MarkContext);
  const shape = SHAPES[mark];
  return (
    <span
      data-slot="draw-heading-mark"
      className={cn("relative inline-block whitespace-nowrap", shape.room, className)}
      {...props}
    >
      {children}
      <svg
        aria-hidden="true"
        data-slot="draw-heading-stroke"
        viewBox={`0 0 ${shape.width} ${shape.height}`}
        preserveAspectRatio="none"
        fill="none"
        className={cn("pointer-events-none absolute max-w-none overflow-visible", shape.place)}
      >
        <path
          d={shape.path}
          vectorEffect="non-scaling-stroke"
          strokeLinecap="round"
          strokeLinejoin="round"
          className={cn("[stroke-width:0.055em]", tone === "brand" ? "stroke-brand" : "stroke-foreground")}
        />
      </svg>
    </span>
  );
}

export { DrawHeading, DrawHeadingMark };

For AI agents

Hand your agent this page, the markdown at /components/draw-heading/prompt.md, or the Copy AI prompt button above.

When to use

  • One phrase in a hero or section heading that carries the point
  • A landing page that wants a human note next to precise type
  • Editorial pages and launch posts

When not to use

  • More than one heading on a screen
  • App chrome and dashboards
  • Phrases long enough to need a second line: the mark stays on one

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • The text never moves or hides, so this is safe on a hero. Only the stroke animates
  • Wrap one phrase in DrawHeadingMark. It is an inline block that does not wrap, so keep it to two or three short words
  • Leave line height at leading-tight or looser, or the underline meets the line below
  • The stroke sits in the markup, so reduced motion and no script both show it drawn. It is aria-hidden

The maths

  • Stroke width is 0.055em, 4px on a 72px heading, so it scales with the type.
  • The path is scaled by W / 300 and H / 24 into CSS pixels before drawing, because DrawSVG cannot measure a non-uniformly stretched stroke, then restored so the mark follows the word.