Skip to content

Components / Text

Stroke Draw

A headline's outline draws itself glyph by glyph when it scrolls into view, then the fill comes in. SVG text with one stroked <tspan> per character, a dash longer than any outline offset to zero on a per-index delay. Composes Reveal; no script of its own. The server HTML holds the finished text.

Live demo

Draw.outline first, fill after

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

CSS 7.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import StrokeDraw from '@moonarc/core/StrokeDraw';
---
<StrokeDraw as="h1" text="Measured." class="text-6xl font-bold" />
<StrokeDraw text="Outline only" fill={false} width={3} color="var(--ma-ink)" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/stroke-draw.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Reveal.astro and runtime.ts, and add the JS gate to your head.

StrokeDraw.astro
---
/**
 * StrokeDraw — the outline of a headline draws itself, then the fill comes
 * in. -webkit-text-stroke cannot be drawn as a stroke, so the text is an
 * SVG <text>, one <tspan> per character, each stroked with a dash longer
 * than any glyph's outline and offset to zero on a per-index delay; the
 * fill fades in once the stroke has landed. pathLength is not defined for
 * <text> in any engine, so the dash is em-based. Composes Reveal for the
 * trigger; zero script of its own. The server HTML holds the final text.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';

interface Props extends HTMLAttributes<'span'> {
  /** The text — one line. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Stroke width, relative to a 100-unit font size (2 ≈ 2% of the cap height). */
  width?: number;
  /** Dash length per glyph in em; the outline of any glyph must fit inside it. */
  dash?: number;
  /** Stroke colour. */
  color?: string;
  /** Fill after the stroke. false leaves the outline. */
  fill?: boolean;
  /** Draw duration per glyph in ms. Unset: the preset's --ma-duration-slow. */
  duration?: number;
  /** Delay between neighbouring glyphs in ms. Unset: the preset's --ma-stagger. */
  step?: number;
  /** Delay in ms before the first glyph. */
  delay?: number;
  /** Play once and stay. */
  once?: boolean;
  /** Fraction visible to trigger. */
  threshold?: number;
}

const { text, as = 'span', width = 2, dash = 5, color = 'currentColor', fill = true, duration, step, delay, once, threshold, class: className, style, ...rest } = Astro.props;

// Advance widths in em, rough Inter metrics: enough to size the viewBox; textLength then justifies the spacing.
const advance = (c: string) => (c === ' ' ? 0.28 : /[ilj.,'!|:;]/.test(c) ? 0.3 : /[frt1I\[\]()]/.test(c) ? 0.38 : /[mwMW]/.test(c) ? 0.9 : /[A-Z0-9]/.test(c) ? 0.68 : 0.56);
const FS = 100;
// one tspan per grapheme, not per code point: a combining accent in a tspan of its own is drawn apart from its letter
const chars = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment);
const w = Math.round(chars.reduce((n, c) => n + advance(c), 0) * FS);
const h = Math.round(FS * 1.25);
const vars = [`--ma-stroke-w:${width}`, `--ma-stroke-dash:${dash}em`, `--ma-stroke-color:${color}`, !fill && '--ma-stroke-fill:transparent', duration !== undefined && `--ma-stroke-dur:${duration}ms`, step !== undefined && `--ma-stroke-step:${step}ms`, typeof style === 'string' ? style : '']
  .filter(Boolean)
  .join(';');
---

<Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-stroke', className].filter(Boolean).join(' ')} style={vars} {...rest}>
  <svg class="ma-stroke__svg" viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
    <text x="0" y={FS} font-size={FS} textLength={w} lengthAdjust="spacing">{chars.map((c, i) => <tspan class="ma-stroke__glyph" style={`--ma-i:${i}`}>{c}</tspan>)}</text>
  </svg>
  <span class="ma-sr">{text}</span>
</Reveal>

<style is:global>
  @layer components {
    :where(.ma-stroke) {
      display: block;
    }
    /* direction: ltr. The line is laid out on the server from x = 0 with textLength, which is a left-to-right layout;
       under a right-to-left page the text started at 0 and ran off the left edge (or reordered its punctuation). */
    :where(.ma-stroke__svg) {
      display: block;
      width: 100%;
      height: auto;
      overflow: visible;
      direction: ltr;
      font-family: inherit;
      font-weight: inherit;
      letter-spacing: inherit;
    }
    :where(.ma-stroke__glyph) {
      fill: var(--ma-stroke-fill, currentColor);
      stroke: var(--ma-stroke-color, currentColor);
      stroke-width: var(--ma-stroke-w, 2);
      stroke-linejoin: round;
      stroke-linecap: round;
      paint-order: stroke;
      stroke-dasharray: var(--ma-stroke-dash, 5em);
      stroke-dashoffset: 0;
      transition:
        stroke-dashoffset var(--ma-stroke-dur, var(--ma-duration-slow)) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-stroke-step, var(--ma-stagger))),
        fill var(--ma-duration) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-stroke-step, var(--ma-stagger)) + var(--ma-stroke-dur, var(--ma-duration-slow)));
    }
    /* Under the JS gate the outlines start undrawn and unfilled; Reveal's .ma-in lets the transitions run. */
    :where([data-ma-js] .ma-stroke:not(.ma-in) .ma-stroke__glyph) {
      stroke-dashoffset: var(--ma-stroke-dash, 5em);
      fill: transparent;
      transition: none;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-stroke__glyph) {
        transition: none;
      }
    }
    /* Print: drawn and filled. A headline printed before it scrolled into view was an empty box. */
    @media print {
      :where(.ma-stroke .ma-stroke__glyph) {
        stroke-dashoffset: 0;
        fill: var(--ma-stroke-fill, currentColor);
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
textstringnoneThe text, one line. Its width follows the container; the viewBox is sized from rough glyph advances and textLength justifies the spacing.
asHTMLTag'span'Element to render.
widthnumber2Stroke width relative to a 100-unit font size. 1.5–3 reads as a pen; more reads as a marker.
dashnumber5Dash length per glyph in em. Every glyph outline must fit inside it (Inter's run 2 to 4.5 em), and a glyph draws faster the shorter its outline is relative to the dash.
colorstring'currentColor'Stroke colour.
fillbooleantrueFill after the stroke lands; false leaves the outline.
durationnumberpreset (700)Draw duration per glyph in ms. Unset, the preset's --ma-duration-slow.
stepnumberpreset (ui: 60)Delay between neighbouring glyphs in ms.
delaynumber0Delay before the first glyph.
oncebooleantruePlay once and stay.
thresholdnumber0.2Fraction visible to trigger.

Reduced motion

The text appears drawn and filled at once.

With ClientRouter

Inherits Reveal: rebinds after every navigation.

Why it is built this way

Replaces: TextPressure / stroke text effects (React Bits) · SVG stroke text with GSAP DrawSVG. See the migration table.

Progress

Loading

A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.

Chrome 116 · not Firefox · Safari 18load

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown