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.

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.

Source
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;
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?numbercontrolled scrape depth (index into `versions`)
defaultDepth?number0initial scrape depth when uncontrolled. Default 0.
onDepthChange?(depth: number) => voidfires whenever the rail commits a new depth
onRestore?(runId: string) => voidfires 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?stringextra classes merged onto the rendered root element