# Scrub chart (Notchset): prompt.md (v2.1.0)

- id: `scrub-chart` · version 2.1.0 · block · free
- category: Marketing
- build: one set of files for every shadcn style
- install: `npx shadcn@latest add https://notchset.dev/r/scrub-chart.json`
- npm dependencies: none
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json, @notchset/chart, @notchset/rolling-number
- docs: https://notchset.dev/blocks/scrub-chart
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

One figure over time against a target, in the chart family's ink style. Drag across it or use the arrow keys and the headline rolls to that point, with how far it sits from the target in words, while a halo and a dark pill follow you. The last point is where it rests..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: none beyond React.
- Files: `components/ui/notchset/blocks/scrub-chart.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `chart`, `rolling-number`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `ScrubChart`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-font-display`, `--ns-font-mono`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ok-ink`, `--ns-panel`, `--ns-series`, `--ns-signal`, `--ns-text-figure`, `--ns-text-meta`, `--ns-text-small`, `--ns-weight-strong`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { ScrubChart } from "@/components/ui/notchset/blocks/scrub-chart";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/blocks/scrub-chart.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when

### Not when

## Built from
- chart (`chart`), installed with it: reuse it, don't rebuild it
- rolling-number (`rolling-number`), installed with it: reuse it, don't rebuild it

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import { ScrubChart } from "@/components/ui/notchset/blocks/scrub-chart";

const money = (v: number) => `$${Math.round(v).toLocaleString("en-US")}`;
const REVENUE = (
  [
    ["Jan", 61200], ["Feb", 58400], ["Mar", 72900], ["Apr", 70100], ["May", 81300], ["Jun", 79800],
    ["Jul", 86400], ["Aug", 91200], ["Sep", 88700], ["Oct", 99400], ["Nov", 104800], ["Dec", 112300],
  ] as const
).map(([label, value]) => ({ label, value }));

export default function ScrubChartDemo() {
  return <ScrubChart title="Revenue won" points={REVENUE} target={85000} format={money} className="w-full max-w-[640px]" />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `title` | `string` |  | Required. |
| `points` | `ScrubPoint[]` |  | Required. |
| `target` | `number` |  | one target for every point, drawn as a dashed line |
| `format` | `(v: number) => string` |  | Required. |

Full docs: https://notchset.dev/blocks/scrub-chart

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/scrub-chart.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/scrub-chart`. Every components.json style gets the same files.

## Source

### components/ui/notchset/blocks/scrub-chart.tsx

```tsx
// Notchset scrub-chart 2.1.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { RollingNumber } from "@/components/ui/notchset/rolling-number";
import { ChartPill, InkArea, smoothPath, useChartWidth } from "@/components/ui/notchset/chart";

/*
 * Scrub chart: one figure over time against a target. Drag across it (or use the arrow keys) and the headline
 * rolls to that point, with how far it sits from the target in words. One line, one dashed target, a hairline
 * cursor; the number is always written, so colour is never the only signal. The last point is where it rests.
 *
 *   <ScrubChart title="Revenue won" points={[{ label: "Jan", value: 42000 }, …]} target={60000} format={money} />
 *
 * Data: values may be negative (the scale runs from the lowest of the values, the target and zero to the highest, and
 * the area fills to zero); a value that isn't a finite number is left out. With no points it says so instead of
 * drawing. The cursor rests on the newest point; a point you pick stays picked (by its label) while the points
 * change, and when it's gone the cursor goes back to the newest.
 */
type ScrubPoint = { label: string; value: number };
type ScrubChartProps = {
  title: string;
  points: ScrubPoint[];
  /** one target for every point, drawn as a dashed line */
  target?: number | undefined;
  format: (v: number) => string;
  className?: string | undefined;
};

const W = 600, H = 200, PAD = 8;

function ScrubChart({ title, points: given, target, format, className }: ScrubChartProps) {
  const points = given.filter((q) => Number.isFinite(q.value));
  const last = points.length - 1;
  /* the label picked, or null to rest on the newest point */
  const [picked, setPicked] = React.useState<string | null>(null);
  const found = picked == null ? -1 : points.findIndex((q) => q.label === picked);
  const at = found >= 0 ? found : last;
  const setAt = (i: number | null) => setPicked(i == null || i === last ? null : (points[i]?.label ?? null));
  const [box, BW, boxEl] = useChartWidth<HTMLDivElement>(600);
  const [reading, setReading] = React.useState(false);
  const t = target != null && Number.isFinite(target) ? target : undefined;
  /* a signed scale: zero, the target and every value sit inside the plot */
  const vals = [...points.map((q) => q.value), t ?? 0, 0];
  const lo = Math.min(...vals), hi = Math.max(...vals);
  const span = (hi - lo) * 1.08 || 1, base = lo < 0 ? lo - (hi - lo) * 0.04 : 0;
  const x = (i: number) => PAD + (i / Math.max(1, last)) * (W - PAD * 2);
  const y = (v: number) => H - PAD - ((v - base) / span) * (H - PAD * 2);
  if (!points.length)
    return (
      <div data-slot="scrub-chart" className={cn("flex w-full flex-col gap-2", className)}>
        <span className="text-[length:var(--ns-text-small)] text-[var(--ns-ink-2)]">{title}</span>
        <p className="m-0 text-[length:var(--ns-text-small)] text-[var(--ns-ink-3)]">Nothing to show yet. Each period&rsquo;s figure appears here as it comes in.</p>
      </div>
    );
  const line = smoothPath(points.map((q, i) => [x(i), y(q.value)]));
  const area = `${line}L${x(last)} ${y(0).toFixed(1)}L${x(0)} ${y(0).toFixed(1)}Z`;
  const p = points[at]!;
  const gap = t != null ? p.value - t : null;
  const pick = (clientX: number) => {
    const r = boxEl?.getBoundingClientRect();
    if (!r || !r.width) return;
    setAt(Math.round(Math.min(1, Math.max(0, (clientX - r.left) / r.width)) * last));
  };
  const words = `${p.label}: ${format(p.value)}${gap != null ? `, ${format(Math.abs(gap))} ${gap >= 0 ? "above" : "below"} target` : ""}`;
  return (
    <div data-slot="scrub-chart" className={cn("flex w-full flex-col gap-4", className)}>
      <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-1">
        <div className="flex min-w-0 flex-col gap-0.5">
          <span className="text-[length:var(--ns-text-small)] text-[var(--ns-ink-2)]">
            {title} · {p.label}
          </span>
          <RollingNumber value={format(p.value)} className="font-[family-name:var(--ns-font-display)] text-[length:var(--ns-text-figure)] leading-none font-[number:var(--ns-weight-strong)] text-[var(--ns-ink)]" />
        </div>
        {gap != null && (
          <span className={cn("text-[length:var(--ns-text-small)] tabular-nums", gap >= 0 ? "text-[var(--ns-ok-ink)]" : "text-[var(--ns-ink-2)]")}>
            {gap >= 0 ? "+" : "−"}
            {format(Math.abs(gap))} {gap >= 0 ? "above" : "below"} target
          </span>
        )}
      </div>
      <div
        ref={box}
        role="slider"
        tabIndex={0}
        aria-label={`${title}, scrub through the period`}
        aria-valuemin={0}
        aria-valuemax={last}
        aria-valuenow={at}
        aria-valuetext={words}
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture?.(e.pointerId);
          setReading(true);
          pick(e.clientX);
        }}
        onPointerMove={(e) => (e.buttons || e.pointerType === "mouse" ? (setReading(true), pick(e.clientX)) : undefined)}
        onPointerLeave={(e) => {
          setReading(false);
          if (e.pointerType === "mouse") setAt(null);
        }}
        onFocus={() => setReading(true)}
        onBlur={() => setReading(false)}
        onKeyDown={(e) => {
          const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : e.key === "Home" ? -last : e.key === "End" ? last : 0;
          if (!step) return;
          e.preventDefault();
          setAt(Math.min(last, Math.max(0, at + step)));
        }}
        className="relative w-full cursor-crosshair touch-none rounded-[10px] outline-hidden select-none focus-visible:shadow-[0_0_0_2px_var(--ns-signal)]"
      >
        <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden className="block h-[180px] w-full overflow-visible">
          <InkArea d={area} top={0} bottom={H} />
          <path d={line} fill="none" stroke="var(--ns-series)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
          {t != null && <line x1={0} x2={W} y1={y(t)} y2={y(t)} stroke="var(--ns-ink-3)" strokeWidth={1.5} strokeDasharray="5 5" vectorEffect="non-scaling-stroke" />}
          <line x1={x(at)} x2={x(at)} y1={0} y2={H} stroke="var(--ns-ink)" strokeOpacity={reading ? 0.26 : 0} strokeWidth={1} vectorEffect="non-scaling-stroke" />
        </svg>
        {/* the point as an HTML halo, so it stays round however the chart stretches */}
        <i
          aria-hidden
          data-ns-glide=""
          className="pointer-events-none absolute size-[11px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--ns-series)] shadow-[0_0_0_2.5px_var(--ns-panel),0_0_0_9px_color-mix(in_oklab,var(--ns-series)_16%,transparent),0_0_18px_8px_color-mix(in_oklab,var(--ns-series)_14%,transparent)] [transition:left_90ms_linear,top_90ms_linear]"
          style={{ left: `${(x(at) / W) * 100}%`, top: `${(y(p.value) / H) * 180}px` }}
        />
        <ChartPill x={(x(at) / W) * BW} y={(y(p.value) / H) * 180} width={BW} on={reading} value={format(p.value)} detail={p.label} />
        <div aria-hidden className="mt-2 flex justify-between font-[family-name:var(--ns-font-mono)] text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)] tabular-nums">
          <span>{points[0]?.label}</span>
          {t != null && <span>Target {format(t)}</span>}
          <span>{points[last]?.label}</span>
        </div>
      </div>
    </div>
  );
}

export { ScrubChart, type ScrubChartProps, type ScrubPoint };
```

The foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://notchset.dev/r/notchset-foundation.json.
