Hatched highlight
A large statement whose key phrases a pen hatches over as they are read, like a proof marked up by hand.
$npx shadcn@latest add https://scrollfx-ui.vercel.app/r/hatch-highlight.json
Adds the component and lib/plotter, and installs svg-scroll-draw. Works in any shadcn/ui project with Tailwind.
'use client';
/**
* hatch-highlight — a large statement whose key phrases a pen hatches over as
* they are read: diagonal strokes plotted one after another, the way you would
* mark up a proof.
*
* Built on svg-scroll-draw (MIT): https://github.com/DhruvilChauahan0210/scrollfx
* Needs: react, svg-scroll-draw, and lib/plotter (installed with it).
* Styling: Tailwind CSS with shadcn/ui tokens. The statement is set in `font-serif`.
*
* Mark a phrase with asterisks: `Most sections *sit still*.` Each mark is
* measured and hatched to fit, and again when the layout changes. The words
* are plain text throughout. Reduced motion shows the hatching drawn.
*/
import { useEffect, useRef, useState } from 'react';
import { ScrollDraw } from 'svg-scroll-draw/react';
import { hatch, scrollWindow, staggerFor, type Stroke } from '@/lib/plotter';
export interface HatchHighlightProps {
eyebrow?: string;
/** The statement. Phrases between asterisks get hatched. */
text?: string;
/** The hatching's colour. */
accent?: string;
className?: string;
}
function Mark({ children, accent }: { children: string; accent: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [box, setBox] = useState<{ w: number; h: number; lines: Stroke[] } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const w = Math.round(el.offsetWidth + 12);
const h = Math.round(el.offsetHeight * 0.62);
setBox((b) => (b && b.w === w && b.h === h ? b : { w, h, lines: hatch({ w, h, gap: 5, angle: 62 }) }));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<span ref={ref} className="relative isolate inline-block whitespace-nowrap">
{box && (
// Hatched as the phrase rises, the last stroke landing as it reaches
// the middle of the screen.
<ScrollDraw key={`${box.w}x${box.h}`} stagger={staggerFor(box.lines.length, 1)} easing="linear" trigger={scrollWindow(1)} className="absolute -left-1.5 top-[30%] -z-10" aria-hidden>
<svg width={box.w} height={box.h} viewBox={`0 0 ${box.w} ${box.h}`} fill="none" stroke={accent} strokeWidth="1.6" strokeLinecap="round" className="block opacity-80">
{box.lines.map((l, i) => <path key={i} d={l.d} />)}
</svg>
</ScrollDraw>
)}
{children}
</span>
);
}
export function HatchHighlight({
eyebrow = 'In short',
text = 'Most sections *sit still*. These are *drawn in* as you read, every line plotted by a pen that keeps pace *with the reader*.',
accent = '#e0482a',
className = '',
}: HatchHighlightProps) {
return (
<section className={`mx-auto max-w-5xl px-6 py-32 sm:px-10 ${className}`}>
<p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
{/* A div, not a p: each mark holds the drawing's wrapper div. */}
<div className="mt-10 text-balance font-serif text-[clamp(2.2rem,4.8vw,4.4rem)] leading-[1.12] tracking-[-0.025em]">
{text.split(/(\*[^*]+\*)/).map((part, i) =>
part.startsWith('*') ? <Mark key={i} accent={accent}>{part.slice(1, -1)}</Mark> : <span key={i}>{part}</span>,
)}
</div>
</section>
);
}
$npx shadcn-vue@latest add https://scrollfx-ui.vercel.app/r/vue/hatch-highlight.json
<script setup lang="ts">
/**
* hatch-highlight — a large statement whose key phrases a pen hatches over as
* they are read: diagonal strokes plotted one after another, the way you would
* mark up a proof.
*
* Built on svg-scroll-draw (MIT): https://github.com/DhruvilChauahan0210/scrollfx
* Needs: vue 3, svg-scroll-draw, and lib/plotter (installed with it).
* Styling: Tailwind CSS with shadcn-vue tokens. The statement is set in `font-serif`.
*
* Mark a phrase with asterisks: `Most sections *sit still*.` Each mark is
* measured and hatched to fit, and again when the layout changes. The words
* are plain text throughout. Reduced motion shows the hatching drawn.
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { ScrollDraw } from 'svg-scroll-draw/vue';
import { hatch, scrollWindow, staggerFor, type Stroke } from '@/lib/plotter';
export interface HatchHighlightProps {
eyebrow?: string;
/** The statement. Phrases between asterisks get hatched. */
text?: string;
/** The hatching's colour. */
accent?: string;
className?: string;
}
const props = withDefaults(defineProps<HatchHighlightProps>(), {
eyebrow: 'In short',
text: 'Most sections *sit still*. These are *drawn in* as you read, every line plotted by a pen that keeps pace *with the reader*.',
accent: '#e0482a',
className: '',
});
const parts = computed(() => props.text.split(/(\*[^*]+\*)/).map((p) => ({ mark: p.startsWith('*'), text: p.startsWith('*') ? p.slice(1, -1) : p })));
// One box per mark, measured from the page and hatched to fit. A function ref
// files each phrase under its own index (an array ref in v-for keeps no order).
const marks: HTMLElement[] = [];
const setMark = (i: number) => (el: unknown) => { if (el) marks[i] = el as HTMLElement; };
const boxes = ref<Record<number, { w: number; h: number; lines: Stroke[] }>>({});
const measure = () => {
const next: typeof boxes.value = {};
marks.forEach((el, i) => {
const w = Math.round(el.offsetWidth + 12);
const h = Math.round(el.offsetHeight * 0.62);
const old = boxes.value[i];
next[i] = old && old.w === w && old.h === h ? old : { w, h, lines: hatch({ w, h, gap: 5, angle: 62 }) };
});
boxes.value = next;
};
let ro: ResizeObserver | undefined;
onMounted(() => {
measure();
ro = new ResizeObserver(measure);
marks.forEach((el) => ro!.observe(el));
});
onBeforeUnmount(() => ro?.disconnect());
const markIndex = (partIndex: number) => parts.value.slice(0, partIndex).filter((p) => p.mark).length;
</script>
<template>
<section :class="['mx-auto max-w-5xl px-6 py-32 sm:px-10', className]">
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{{ eyebrow }}</p>
<div class="mt-10 text-balance font-serif text-[clamp(2.2rem,4.8vw,4.4rem)] leading-[1.12] tracking-[-0.025em]">
<template v-for="(p, i) in parts" :key="i">
<span v-if="p.mark" :ref="setMark(markIndex(i))" class="relative isolate inline-block whitespace-nowrap">
<!-- Hatched as the phrase rises, the last stroke landing as it reaches the middle of the screen. -->
<ScrollDraw
v-if="boxes[markIndex(i)]"
:key="`${boxes[markIndex(i)].w}x${boxes[markIndex(i)].h}`"
:stagger="staggerFor(boxes[markIndex(i)].lines.length, 1)"
easing="linear"
:trigger="scrollWindow(1)"
class="absolute -left-1.5 top-[30%] -z-10"
aria-hidden="true"
>
<svg :width="boxes[markIndex(i)].w" :height="boxes[markIndex(i)].h" :viewBox="`0 0 ${boxes[markIndex(i)].w} ${boxes[markIndex(i)].h}`" fill="none" :stroke="accent" stroke-width="1.6" stroke-linecap="round" class="block opacity-80">
<path v-for="(l, k) in boxes[markIndex(i)].lines" :key="k" :d="l.d" />
</svg>
</ScrollDraw>
{{ p.text }}
</span>
<span v-else>{{ p.text }}</span>
</template>
</div>
</section>
</template>
$npx shadcn-svelte@latest add https://scrollfx-ui.vercel.app/r/svelte/hatch-highlight.json
<script lang="ts" module>
/**
* hatch-highlight — a large statement whose key phrases a pen hatches over as
* they are read: diagonal strokes plotted one after another, the way you would
* mark up a proof.
*
* Built on svg-scroll-draw (MIT): https://github.com/DhruvilChauahan0210/scrollfx
* Needs: svelte 5, svg-scroll-draw, and $lib/plotter (installed with it).
* Styling: Tailwind CSS with shadcn-svelte tokens. The statement is set in `font-serif`.
*
* Mark a phrase with asterisks: `Most sections *sit still*.` Each mark is
* measured and hatched to fit, and again when the layout changes. The words
* are plain text throughout. Reduced motion shows the hatching drawn.
*/
export interface HatchHighlightProps {
eyebrow?: string;
/** The statement. Phrases between asterisks get hatched. */
text?: string;
/** The hatching's colour. */
accent?: string;
className?: string;
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { scrollDraw } from 'svg-scroll-draw/svelte';
import { hatch, scrollWindow, staggerFor, type Stroke } from '$lib/plotter';
let {
eyebrow = 'In short',
text = 'Most sections *sit still*. These are *drawn in* as you read, every line plotted by a pen that keeps pace *with the reader*.',
accent = '#e0482a',
className = '',
}: HatchHighlightProps = $props();
// Each part, and for a mark its index among the marks.
const parts = $derived.by(() => {
let mark = 0;
return text.split(/(\*[^*]+\*)/).map((p) => (p.startsWith('*') ? { mark: mark++, text: p.slice(1, -1) } : { mark: -1, text: p }));
});
// One box per mark, measured from the page and hatched to fit.
const marks: HTMLElement[] = $state([]);
let boxes = $state<Record<number, { w: number; h: number; lines: Stroke[] }>>({});
const measure = () => {
const next: typeof boxes = {};
marks.forEach((el, i) => {
if (!el) return;
const w = Math.round(el.offsetWidth + 12);
const h = Math.round(el.offsetHeight * 0.62);
const old = boxes[i];
next[i] = old && old.w === w && old.h === h ? old : { w, h, lines: hatch({ w, h, gap: 5, angle: 62 }) };
});
boxes = next;
};
onMount(() => {
measure();
const ro = new ResizeObserver(measure);
marks.forEach((el) => el && ro.observe(el));
return () => ro.disconnect();
});
</script>
<section class="mx-auto max-w-5xl px-6 py-32 sm:px-10 {className}">
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
<div class="mt-10 text-balance font-serif text-[clamp(2.2rem,4.8vw,4.4rem)] leading-[1.12] tracking-[-0.025em]">
{#each parts as p, i (i)}
{#if p.mark >= 0}
<span bind:this={marks[p.mark]} class="relative isolate inline-block whitespace-nowrap">
{#if boxes[p.mark]}
{@const box = boxes[p.mark]}
{#key `${box.w}x${box.h}`}
<!-- Hatched as the phrase rises, the last stroke landing as it reaches the middle of the screen. -->
<span use:scrollDraw={{ stagger: staggerFor(box.lines.length, 1), easing: 'linear', trigger: scrollWindow(1) }} class="absolute -left-1.5 top-[30%] -z-10 block" aria-hidden="true">
<svg width={box.w} height={box.h} viewBox="0 0 {box.w} {box.h}" fill="none" stroke={accent} stroke-width="1.6" stroke-linecap="round" class="block opacity-80">
{#each box.lines as l, k (k)}<path d={l.d} />{/each}
</svg>
</span>
{/key}
{/if}
{p.text}
</span>
{:else}
<span>{p.text}</span>
{/if}
{/each}
</div>
</section>