# Stroke Draw (Moonarc)

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.

- Import: `import StrokeDraw from '@moonarc/core/StrokeDraw'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/stroke-draw.json`
- Tier A · category text · trigger scroll
- Readout: `<StrokeDraw text="Draw.">`
- Browser support: widely (every browser)
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 7.9 kB raw)
- Page: https://moonarc.dev/components/stroke-draw/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text, one line. Its width follows the container; the viewBox is sized from rough glyph advances and textLength justifies the spacing. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `width` | `number` | `2` | Stroke width relative to a 100-unit font size. 1.5–3 reads as a pen; more reads as a marker. |
| `dash` | `number` | `5` | Dash 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. |
| `color` | `string` | `'currentColor'` | Stroke colour. |
| `fill` | `boolean` | `true` | Fill after the stroke lands; false leaves the outline. |
| `duration` | `number` | `preset (700)` | Draw duration per glyph in ms. Unset, the preset's --ma-duration-slow. |
| `step` | `number` | `preset (ui: 60)` | Delay between neighbouring glyphs in ms. |
| `delay` | `number` | `0` | Delay before the first glyph. |
| `once` | `boolean` | `true` | Play once and stay. |
| `threshold` | `number` | `0.2` | Fraction visible to trigger. |

## Usage

```astro
<StrokeDraw as="h1" text="Measured." class="text-6xl font-bold" />
<StrokeDraw text="Outline only" fill={false} width={3} color="var(--ma-ink)" />
```

## Reduced motion

The text appears drawn and filled at once.

## With ClientRouter

Inherits Reveal: rebinds after every navigation.

## Craft

- pathLength does not apply to <text> in any engine (SVG 2 defines it for shapes), so the dash cannot be normalised to 1; it is 5 em per glyph instead, an upper bound on any Latin outline, and every glyph finishes inside the same duration.
- One <tspan> per character (a grapheme, so a letter with a combining accent is drawn whole) with its own delay: the headline is written, not revealed. A single dash on the whole <text> would draw every letter at once.
- Fill waits for the stroke: the transition on fill is delayed by the draw duration, so ink arrives after the pen.
- paint-order: stroke keeps the stroke under the fill, so the finished letter is the plain glyph with no bold outline.
- The <svg> is sized by its viewBox and fills the container width; nothing is measured on the client and the text scales like a headline should.
- The line is laid out left to right from x = 0 whatever the page's direction: textLength fits it from there, and under dir="rtl" it ran off the left edge with its full stop in front.

## Replaces

- TextPressure / stroke text effects (React Bits)
- SVG stroke text with GSAP DrawSVG

## Source

```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>

```
