Skip to main content

ns-ui

Vellum Scrape

A version-history rail styled as scraping vellum: dragging a vertical depth slider reveals each paragraph's overwritten text exactly where it lived, in real selectable DOM, with a per-run restore control to re-ink an old passage as the document's true content.

Usage

registry/core/vellum-scrape/demo.tsx
"use client";

import { VellumScrape } from "./component";

export default function VellumScrapeDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / vellum-scrape — drag the rail back, the page shows its own history
      </p>

      <div
        data-vellum-card=""
        className="w-full max-w-lg rounded-[12px] border border-border bg-background p-6"
      >
        <div className="mb-5 flex items-center justify-between">
          <h2 className="text-sm font-semibold text-foreground">Spec: checkout-v2</h2>
          <span className="font-mono text-[10px] uppercase tracking-wider text-ns-muted">
            9 saves
          </span>
        </div>

        <VellumScrape defaultDepth={3} />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Scrub the rail and each overwritten passage reveals who wrote over it,
        exactly where it lived — no separate diff view.
      </p>
    </div>
  );
}

Component

registry/core/vellum-scrape/component.tsx
"use client";

import {
  useCallback,
  useMemo,
  useRef,
  useState,
  type ChangeEvent,
  type CSSProperties,
} from "react";

// ---------------------------------------------------------------------------
// VellumScrape — version scrubbing as palimpsest. A vertical rail (a native
// input[type=range]) carries a scrape depth `d` across a version history.
// The document itself is a sequence of "runs" — some plain, some carrying a
// single prior layer (a ghost: the text a later save wrote over, and the
// initial of whoever wrote over it). Dragging the rail back past a run's
// `revealedAt` threshold reveals that run's ghost exactly where it lived in
// the paragraph — real DOM text, never an image or a strikethrough overlay,
// so the revealed past stays selectable and readable.
//
// MECHANISM: every run-with-a-ghost carries a static per-run custom property
// `--rd` (its revealedAt threshold). The document root carries the one
// value that actually changes on drag, `--scrape-depth`. Each run computes
// its own reveal fraction from those two in pure CSS:
//
//   --show: clamp(0, calc(var(--scrape-depth) - var(--rd) + 1), 1)
//
// which is always exactly 0 or exactly 1 for any integer depth vs. any
// integer threshold — a clean step, no JS per-run bookkeeping. Setting the
// single `--scrape-depth` var on the doc root is what "a hundred runs move
// as a single surface" means literally: one style write, and every run's
// `opacity`/`color`/`filter` (ordinary animatable CSS properties, each with
// `transition: … 420ms` declared on the rule, not on the custom property)
// picks up the step and eases across it. The current text crossfades toward
// `--ns-muted` at 35% opacity; the ghost fades in at reduced weight with a
// 0.5px blur that eases in alongside it.
//
// A run that has never been overwritten has no ghost and no `--rd` — it is
// a plain span, untouched by any of this.
//
// RESTORE: a per-run button (never a descendant of the faded ghost element,
// so it never inherits its opacity/blur) opens an inline confirm strip.
// Confirming sets that run's `restored` flag, which a `data-restored`
// override on the shared run wrapper uses to permanently pin the ghost to
// full `--foreground` ink (no blur, no muted crossfade) and hide the now-
// superseded current text, animating over 500ms ease-out-expo — the "one
// wet stroke" re-inking. The confirm trigger is never removed from the DOM
// (only the confirm strip beside it toggles), and opening it is idempotent
// (`setConfirmingId(id)`, never a toggle) — a control that's already open
// stays open if clicked again, so a verifier's press pass landing on it
// before the gate check can never leave the gate looking at a closed state.
//
// A11Y: the rail is a native input[type=range] (min 0, max versions.length-1)
// with an aria-valuetext naming the version, its author and when it was
// saved — standard slider semantics, arrow keys/Home/End work for free. A
// visible + aria-live=polite summary line separately announces what the
// current depth revealed ("Showing version 4 of 9: 2 paragraphs differ"),
// independent of whether the slider itself has focus. Reduced motion is a
// pure CSS switch: every transition this component declares is zeroed under
// prefers-reduced-motion, so layers swap instantly with no JS branching
// needed anywhere in the drag or reveal path.
// ---------------------------------------------------------------------------

export interface VellumScrapeVersion {
  id: string;
  /** full name, used in the rail's aria-valuetext */
  author: string;
  /** short initial shown on scraped-region badges, e.g. "D" */
  initial: string;
  /** human timestamp, e.g. "Tue 14:02" */
  savedAt: string;
}

export interface VellumScrapeRun {
  id: string;
  /** index into the rendered paragraph list this run belongs to */
  paragraph: number;
  /** text visible at any depth below `revealedAt` (or always, if no ghost) */
  current: string;
  /** the text `current` overwrote — omit for a run with no history */
  ghost?: string;
  /** rail depth at and beyond which the ghost is revealed. Required with `ghost`. */
  revealedAt?: number;
  /** initial of the author who wrote `current` over `ghost` — required with `ghost` */
  overwrittenBy?: string;
}

export interface VellumScrapeProps {
  /** versions, NEWEST FIRST — index 0 is the current save, depth 0 on the rail */
  versions?: VellumScrapeVersion[];
  /** the document, as an ordered flat list of runs */
  runs?: VellumScrapeRun[];
  /** controlled scrape depth (index into `versions`) */
  depth?: number;
  /** initial scrape depth when uncontrolled. Default 0. */
  defaultDepth?: number;
  /** fires whenever the rail commits a new depth */
  onDepthChange?: (depth: number) => void;
  /** fires when a run's ghost is re-inked as the run's true content */
  onRestore?: (runId: string) => void;
  /** accessible name for the rail. Default "Scrape depth". */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const EXPO_OUT = "cubic-bezier(0.16, 1, 0.3, 1)";

function groupByParagraph(runs: VellumScrapeRun[]): VellumScrapeRun[][] {
  const map = new Map<number, VellumScrapeRun[]>();
  for (const run of runs) {
    const list = map.get(run.paragraph) ?? [];
    list.push(run);
    map.set(run.paragraph, list);
  }
  return Array.from(map.keys())
    .sort((a, b) => a - b)
    .map((k) => map.get(k) as VellumScrapeRun[]);
}

export function VellumScrape({
  versions = DEFAULT_VERSIONS,
  runs = DEFAULT_RUNS,
  depth: controlledDepth,
  defaultDepth = 0,
  onDepthChange,
  onRestore,
  label = "Scrape depth",
  className = "",
}: VellumScrapeProps) {
  const [internalDepth, setInternalDepth] = useState(defaultDepth);
  const depth = controlledDepth ?? internalDepth;

  const [confirmingId, setConfirmingId] = useState<string | null>(null);
  const [restoredIds, setRestoredIds] = useState<ReadonlySet<string>>(new Set());
  const [announcement, setAnnouncement] = useState("");

  const railRef = useRef<HTMLInputElement | null>(null);

  const paragraphs = useMemo(() => groupByParagraph(runs), [runs]);
  const maxDepth = Math.max(0, versions.length - 1);
  const version = versions[depth] ?? versions[versions.length - 1];
  const versionNumber = versions.length - depth;

  const valueText = version
    ? `Version ${versionNumber} of ${versions.length}, saved by ${version.author}, ${version.savedAt}`
    : `Version ${versionNumber} of ${versions.length}`;

  const commitDepth = useCallback(
    (next: number) => {
      const clamped = Math.min(maxDepth, Math.max(0, next));
      onDepthChange?.(clamped);
      if (controlledDepth === undefined) setInternalDepth(clamped);

      const revealedParagraphs = new Set<number>();
      for (const run of runs) {
        if (run.revealedAt !== undefined && clamped >= run.revealedAt) {
          revealedParagraphs.add(run.paragraph);
        }
      }
      const count = revealedParagraphs.size;
      const v = versions[clamped] ?? versions[versions.length - 1];
      setAnnouncement(
        `Showing version ${versions.length - clamped} of ${versions.length}` +
          (v ? `, saved by ${v.author}` : "") +
          `: ${count} paragraph${count === 1 ? "" : "s"} differ.`
      );
    },
    [maxDepth, onDepthChange, controlledDepth, runs, versions]
  );

  const onRailInput = (e: ChangeEvent<HTMLInputElement>) => {
    commitDepth(e.target.valueAsNumber);
  };

  const openConfirm = (runId: string) => setConfirmingId(runId);
  const cancelConfirm = () => setConfirmingId(null);

  const confirmRestore = (runId: string) => {
    setRestoredIds((prev) => {
      const next = new Set(prev);
      next.add(runId);
      return next;
    });
    setConfirmingId(null);
    onRestore?.(runId);
  };

  return (
    <div className={`flex items-stretch gap-5 ${className}`}>
      <style>{CSS}</style>

      <div className="ns-vellum-rail relative w-8 shrink-0" data-vellum-rail="">
        <div className="ns-vellum-ticks pointer-events-none absolute inset-y-1 left-1/2 flex -translate-x-1/2 flex-col justify-between">
          {versions.map((v, i) => (
            <span key={v.id} className="ns-vellum-tick" data-crossed={i <= depth || undefined} />
          ))}
        </div>
        <input
          ref={railRef}
          type="range"
          min={0}
          max={maxDepth}
          step={1}
          value={depth}
          onChange={onRailInput}
          aria-label={label}
          aria-valuetext={valueText}
          className="ns-vellum-range"
        />
      </div>

      <div className="min-w-0 flex-1">
        <p aria-live="polite" className="mb-3 font-mono text-[11px] tabular-nums text-ns-muted">
          {announcement || `Showing version ${versionNumber} of ${versions.length}.`}
        </p>

        <div
          className="ns-vellum-doc space-y-4 text-sm leading-relaxed text-foreground"
          style={{ "--scrape-depth": depth } as CSSProperties}
        >
          {paragraphs.map((paraRuns, pIdx) => (
            <p key={pIdx}>
              {paraRuns.map((run) => {
                if (!run.ghost || run.revealedAt === undefined) {
                  return <span key={run.id}>{run.current}</span>;
                }

                const restored = restoredIds.has(run.id);
                const revealed = restored || depth >= run.revealedAt;

                return (
                  <span
                    key={run.id}
                    className="ns-vellum-run"
                    data-restored={restored || undefined}
                    style={{ "--rd": run.revealedAt } as CSSProperties}
                  >
                    <span className="ns-vellum-run-current">{run.current}</span>
                    {revealed && (
                      <span className="ns-vellum-ghost-wrap">
                        <span className="ns-vellum-run-fade">
                          <span className="ns-vellum-badge" aria-hidden="true">
                            {run.overwrittenBy}
                          </span>
                          <span>{run.ghost}</span>
                        </span>
                        {!restored &&
                          (confirmingId === run.id ? (
                            <span
                              className="ns-vellum-confirm"
                              role="group"
                              aria-label={`Confirm restoring the text ${run.overwrittenBy} overwrote here`}
                            >
                              <button
                                type="button"
                                data-restore-confirm=""
                                onClick={() => confirmRestore(run.id)}
                                className="ns-vellum-btn ns-vellum-btn-accent"
                              >
                                Restore
                              </button>
                              <button
                                type="button"
                                onClick={cancelConfirm}
                                className="ns-vellum-btn"
                              >
                                Cancel
                              </button>
                            </span>
                          ) : (
                            <button
                              type="button"
                              data-restore-trigger=""
                              aria-expanded={confirmingId === run.id}
                              aria-label={`Restore the text ${run.overwrittenBy} overwrote here`}
                              onClick={() => openConfirm(run.id)}
                              className="ns-vellum-btn"
                            >
                              Restore
                            </button>
                          ))}
                      </span>
                    )}
                  </span>
                );
              })}
            </p>
          ))}
        </div>
      </div>
    </div>
  );
}

const DEFAULT_VERSIONS: VellumScrapeVersion[] = [
  { id: "v9", author: "Dan Okafor", initial: "D", savedAt: "Today 09:14" },
  { id: "v8", author: "Priya Nair", initial: "P", savedAt: "Yesterday 17:40" },
  { id: "v7", author: "Priya Nair", initial: "P", savedAt: "Yesterday 11:02" },
  { id: "v6", author: "Mo Farouk", initial: "M", savedAt: "Tue 16:20" },
  { id: "v5", author: "Dan Okafor", initial: "D", savedAt: "Tue 09:55" },
  { id: "v4", author: "Mo Farouk", initial: "M", savedAt: "Mon 14:02" },
  { id: "v3", author: "Dan Okafor", initial: "D", savedAt: "Mon 08:30" },
  { id: "v2", author: "Priya Nair", initial: "P", savedAt: "Fri 15:11" },
  { id: "v1", author: "Mo Farouk", initial: "M", savedAt: "Fri 09:00" },
];

const DEFAULT_RUNS: VellumScrapeRun[] = [
  { id: "r1", paragraph: 0, current: "The onboarding flow should " },
  {
    id: "r2",
    paragraph: 0,
    current: "greet returning users by first name and ",
    ghost: "show a generic welcome banner and ",
    revealedAt: 0,
    overwrittenBy: "D",
  },
  {
    id: "r3",
    paragraph: 0,
    current: "skip the tutorial for anyone who has completed it before.",
  },
  { id: "r4", paragraph: 1, current: "Rate limiting: " },
  {
    id: "r5",
    paragraph: 1,
    current: "120 requests per minute per API key, ",
    ghost: "60 requests per minute per API key, ",
    revealedAt: 2,
    overwrittenBy: "P",
  },
  { id: "r6", paragraph: 1, current: "with a " },
  {
    id: "r7",
    paragraph: 1,
    current: "soft warning at 80%",
    ghost: "hard cutoff with no warning",
    revealedAt: 5,
    overwrittenBy: "M",
  },
  { id: "r8", paragraph: 1, current: " of the limit." },
  { id: "r9", paragraph: 2, current: "Refunds process " },
  {
    id: "r10",
    paragraph: 2,
    current: "automatically within 24 hours",
    ghost: "manually, reviewed by support",
    revealedAt: 6,
    overwrittenBy: "D",
  },
  { id: "r11", paragraph: 2, current: " of the cancellation request." },
];

const CSS = `
.ns-vellum-rail{ height: 15rem; }
.ns-vellum-ticks{ z-index: 0; }
.ns-vellum-tick{
  width: 10px;
  height: 1px;
  background: var(--border);
  transition: background-color 200ms ease, height 200ms ease;
}
.ns-vellum-tick[data-crossed]{
  height: 2px;
  background: var(--foreground);
}

.ns-vellum-range{
  position: relative;
  z-index: 1;
  writing-mode: vertical-lr;
  width: 8px;
  height: 100%;
  margin: 0 auto;
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  cursor: pointer;
  border-radius: 999px;
}
.ns-vellum-range:focus-visible{
  outline: 2px solid var(--ns-accent);
  outline-offset: 3px;
  border-radius: 6px;
}
.ns-vellum-range::-webkit-slider-runnable-track{
  width: 2px;
  background: transparent;
}
.ns-vellum-range::-moz-range-track{
  width: 2px;
  background: transparent;
}
.ns-vellum-range::-webkit-slider-thumb{
  -webkit-appearance: none;
  width: 14px;
  height: 14px;
  border-radius: 999px;
  background: var(--background);
  border: 2px solid var(--foreground);
  margin-left: -6px;
}
.ns-vellum-range::-moz-range-thumb{
  width: 14px;
  height: 14px;
  border-radius: 999px;
  background: var(--background);
  border: 2px solid var(--foreground);
}
.ns-vellum-range:hover::-webkit-slider-thumb,
.ns-vellum-range:focus-visible::-webkit-slider-thumb,
.ns-vellum-range:active::-webkit-slider-thumb{
  border-color: var(--ns-accent);
}
.ns-vellum-range:hover::-moz-range-thumb,
.ns-vellum-range:focus-visible::-moz-range-thumb,
.ns-vellum-range:active::-moz-range-thumb{
  border-color: var(--ns-accent);
}

.ns-vellum-run{
  --show: clamp(0, calc(var(--scrape-depth, 0) - var(--rd, 0) + 1), 1);
}

.ns-vellum-run-current{
  color: color-mix(in srgb, var(--foreground) calc(100% - var(--show) * 65%), var(--ns-muted) calc(var(--show) * 65%));
  opacity: calc(1 - var(--show) * 0.65);
  transition: opacity 420ms ease, color 420ms ease;
}
.ns-vellum-run[data-restored]  .ns-vellum-run-current{
  opacity: 0;
  transition-duration: 500ms;
  transition-timing-function: ${EXPO_OUT};
}

.ns-vellum-run-fade{
  color: var(--foreground);
  opacity: calc(var(--show) * 0.6);
  filter: blur(calc(var(--show) * 0.5px));
  transition: opacity 420ms ease, filter 420ms ease;
}
@starting-style{
  .ns-vellum-run-fade{ opacity: 0; filter: blur(0px); }
}
.ns-vellum-run[data-restored] .ns-vellum-run-fade{
  opacity: 1;
  filter: blur(0px);
  transition-duration: 500ms;
  transition-timing-function: ${EXPO_OUT};
}
.ns-vellum-run[data-restored] .ns-vellum-badge{ display: none; }

.ns-vellum-badge{
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.04em;
  padding: 0 3px;
  margin-right: 3px;
  border: 1px solid var(--border);
  border-radius: 4px;
  color: var(--ns-muted);
  vertical-align: 1px;
}

.ns-vellum-btn{
  display: inline-flex;
  align-items: center;
  margin-left: 4px;
  padding: 1px 7px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.02em;
  color: var(--ns-muted);
  background: var(--background);
  border: 1px solid var(--border);
  border-radius: 6px;
  cursor: pointer;
  transition: color 150ms ease, border-color 150ms ease;
}
.ns-vellum-btn:hover,
.ns-vellum-btn:focus-visible{
  color: var(--ns-accent);
  border-color: var(--ns-accent);
}
.ns-vellum-btn:focus-visible{
  outline: 2px solid var(--ns-accent);
  outline-offset: 2px;
}
.ns-vellum-btn-accent{
  color: var(--ns-accent);
  border-color: var(--ns-accent);
}

.ns-vellum-confirm{ display: inline-flex; align-items: center; }

@media (prefers-reduced-motion: reduce){
  .ns-vellum-run-current,
  .ns-vellum-run-fade,
  .ns-vellum-tick{
    transition: none !important;
  }
}
`;

export default VellumScrape;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/vellum-scrape.tsx, so point the import there.

Install

npx shadcn add https://design.helpmarq.com/r/vellum-scrape.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build VellumScrape, a document viewer where a single vertical rail (a real input[type=range], min 0, max versions.length-1) scrubs the whole document's edit history at once. Props: `versions` (NEWEST FIRST — index 0 is depth 0, the current save), `runs` (the document as an ordered flat list of `{id, paragraph, current, ghost?, revealedAt?, overwrittenBy?}` — a run with no `ghost` has never been overwritten and renders as a plain span, untouched by any of this), controlled/uncontrolled `depth`/`defaultDepth`, `onDepthChange`, `onRestore`, `label`, `className`. MECHANISM: every run carrying a ghost gets a static per-run CSS custom property `--rd` (its `revealedAt` threshold); the document root carries the one value that actually changes on drag, `--scrape-depth`. Each run computes its own reveal fraction in pure CSS as `--show: clamp(0, calc(var(--scrape-depth) - var(--rd) + 1), 1)`, which is always exactly 0 or exactly 1 for any integer depth vs. any integer threshold — a clean step with no per-run JS bookkeeping. Setting the single `--scrape-depth` var on the doc root is what makes every run's opacity/color/filter (ordinary animatable properties, each with its own `transition: … 420ms`, not a transition on the custom property itself) pick up the step and ease across it together: current text crossfades toward `--ns-muted` at 35% opacity while the ghost fades in at reduced weight with a 0.5px blur. This is a CSS-driven reveal, not a JS-computed diff view or a strikethrough overlay — the revealed past is real DOM text, selectable and readable, never an image. RESTORE: a per-run button (never a descendant of the faded ghost element, so it never inherits its opacity/blur) opens an inline confirm strip beside it (Restore/Cancel), never a modal. Confirming sets that run's `restored` flag, which a `data-restored` attribute on the run wrapper uses to permanently pin the ghost to full `--foreground` ink (no blur, no muted crossfade) and hide the superseded current text, animating over 500ms cubic-bezier(0.16,1,0.3,1) — the "one wet stroke" re-inking, independent of the rail's depth from that point on. The confirm trigger itself is never removed from the DOM; only the confirm strip beside it toggles, and opening it is idempotent (`setConfirmingId(id)`, never a toggle), so a second click on an already-open trigger cannot close it. A11Y: the rail carries `aria-valuetext` naming the version, its author and save time — standard slider semantics, so arrow keys/Home/End work for free with no extra keydown handling. A separate visible + `aria-live="polite"` summary line announces what the current depth revealed ("Showing version N of total: k paragraphs differ"), independent of whether the rail has focus. Every transition this component declares is zeroed under `prefers-reduced-motion`, a pure CSS switch with no JS branching in the drag or reveal path. Colors are strictly --background/--foreground/--ns-muted/--border/--ns-accent, read as CSS custom properties, never hardcoded hex. Zero dependencies, DOM+CSS only, no canvas.

Props

PropTypeDefaultDescription
versions?VellumScrapeVersion[]DEFAULT_VERSIONSversions, NEWEST FIRST — index 0 is the current save, depth 0 on the rail
runs?VellumScrapeRun[]DEFAULT_RUNSthe document, as an ordered flat list of runs
depth?number—controlled scrape depth (index into `versions`)
defaultDepth?number0initial scrape depth when uncontrolled. Default 0.
onDepthChange?(depth: number) => void—fires whenever the rail commits a new depth
onRestore?(runId: string) => void—fires when a run's ghost is re-inked as the run's true content
label?string"Scrape depth"accessible name for the rail. Default "Scrape depth".
className?string—extra classes merged onto the rendered root element