Skip to main content

ns-ui

Contra Strike

Order line-item ledger where a partial refund drags a struck span across the amount bar and, on commit, appends a negative contra row beneath the original, which is never edited, only ever struck through and kept legible.

Usage

registry/core/contra-strike/demo.tsx
"use client";

import { ContraStrike, type ContraStrikeLine } from "./component";

const LINES: ContraStrikeLine[] = [
  {
    id: "mouse",
    description: "Wireless Mouse ×6",
    qty: 6,
    unitPrice: 12.5,
    defaultStruck: 2 / 6,
  },
  {
    id: "keyboard",
    description: "Keyboard ×2",
    qty: 2,
    unitPrice: 45,
    defaultStruck: 0.5,
  },
  {
    id: "shipping",
    description: "Priority Shipping",
    qty: 1,
    unitPrice: 18,
    committedStruck: 1,
  },
];

export default function ContraStrikeDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center px-6 py-16">
      <div className="w-full max-w-md">
        <p className="mb-3 font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / contra-strike
        </p>
        <ContraStrike
          orderLabel="Order #4471"
          lines={LINES}
          onCommit={(line, refundAmount) => {
            console.log(`refunded ${refundAmount.toFixed(2)} on ${line.description}`);
          }}
        />
      </div>
    </div>
  );
}

Component

registry/core/contra-strike/component.tsx
"use client";

import { Fragment, useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// ContraStrike — an order's line-item ledger, rendered as a real <table>,
// where a partial refund is recorded the way bookkeepers actually record one:
// nothing about the original entry is ever touched. Dragging along a line's
// amount bar arms a struck fraction `s` (a single governing scalar) that
// simultaneously sizes the refund figure, the length of an SVG rule crossing
// the amount, and — once a real "Refund $x" button commits it — the width of
// a brand-new contra row's own bar on the SAME money-per-px scale. The
// struck row's DOM node is never edited or removed: its figures stay exactly
// as billed, decorated (not deleted) with a permanent line-through, and the
// correction lives one row down, forever, in Geist Mono with a leading
// minus. Quantity lines snap the drag to whole units (2 of 6, never 2.4 of
// 6); a flat line with no quantity snaps to the cent instead. The rule stays
// dashed for as long as the row is only armed, not booked — a mid-drag
// selection must never read as an already-settled correction — and turns
// solid at the same instant the contra row is appended and the balance
// re-derives.
// ---------------------------------------------------------------------------

export interface ContraStrikeLine {
  id: string;
  /** line description, e.g. "Wireless Mouse ×6" */
  description: string;
  /** unit count on this line — 1 for a flat/non-quantity line */
  qty: number;
  /** price per unit, dollars */
  unitPrice: number;
  /** seed this line as already armed (dragged, not yet committed), 0-1 */
  defaultStruck?: number;
  /** seed this line as already refunded at mount — contra row renders open,
   * no animation, matching how a history feed replays a past decision */
  committedStruck?: number;
}

export interface ContraStrikeProps {
  /** heading shown above the ledger, e.g. "Order #4471" */
  orderLabel?: string;
  /** the order's line items */
  lines: ContraStrikeLine[];
  /** ISO currency code passed to Intl.NumberFormat */
  currency?: string;
  /** BCP 47 locale passed to Intl.NumberFormat — always explicit, never the
   * runtime default, so server and client render identical digits */
  locale?: string;
  /** fired once per line the moment its contra row is committed */
  onCommit?: (line: ContraStrikeLine, refundAmount: number, struckUnits: number) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

type LineState = {
  input: ContraStrikeLine;
  amount: number;
  struck: number; // 0-1, the armed/committed fraction — the one governing scalar
  dragging: boolean;
  committed: boolean;
  contraOpen: boolean; // flips true one frame after `committed` so the height
  // transition has a 0-height frame to animate FROM, per prevailing bug
};

const TRACK_W = 148; // px — the shared money-per-px track width every bar maps onto
const ROW_H = 30; // px — fixed row height, both the original and contra rows

function clamp(v: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, v));
}

function snapFraction(raw: number, qty: number, amount: number): number {
  const r = clamp(raw, 0, 1);
  if (qty > 1) {
    const k = Math.round(r * qty);
    return clamp(k / qty, 0, 1);
  }
  const cents = Math.round(amount * 100);
  if (cents <= 0) return 0;
  const c = Math.round(r * cents);
  return clamp(c / cents, 0, 1);
}

function stepFraction(qty: number, amount: number): number {
  if (qty > 1) return 1 / qty;
  const cents = Math.round(amount * 100);
  return cents > 0 ? 1 / cents : 0;
}

export function ContraStrike({
  orderLabel = "Order",
  lines,
  currency = "USD",
  locale = "en-US",
  onCommit,
  className = "",
}: ContraStrikeProps) {
  const money = useMemo(() => {
    const fmt = new Intl.NumberFormat(locale, { style: "currency", currency });
    return (v: number) => fmt.format(v);
  }, [locale, currency]);

  const [rows, setRows] = useState<LineState[]>(() =>
    lines.map((input) => {
      const amount = input.qty * input.unitPrice;
      const committed = input.committedStruck !== undefined;
      const struck = committed
        ? clamp(input.committedStruck as number, 0, 1)
        : clamp(input.defaultStruck ?? 0, 0, 1);
      return { input, amount, struck, dragging: false, committed, contraOpen: committed };
    })
  );

  const maxAmount = useMemo(
    () => Math.max(1, ...rows.map((r) => r.amount)),
    [rows]
  );
  const scale = TRACK_W / maxAmount;
  const barPx = (amount: number) => Math.max(2, amount * scale);

  const [reducedMotion, setReducedMotion] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReducedMotion(mq.matches);
    const onChange = () => setReducedMotion(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  const [announcement, setAnnouncement] = useState("");
  const [flashIdx, setFlashIdx] = useState<number | null>(null);
  const [balanceFlash, setBalanceFlash] = useState(false);
  const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => () => {
    if (flashTimer.current) clearTimeout(flashTimer.current);
  }, []);

  function updateStruck(idx: number, raw: number, dragging: boolean) {
    setRows((prev) =>
      prev.map((r, i) => {
        if (i !== idx || r.committed) return r;
        const q = snapFraction(raw, r.input.qty, r.amount);
        return { ...r, struck: q, dragging };
      })
    );
  }

  function trackDrag(idx: number, clientX: number, dragging: boolean) {
    const el = trackRefs.current[idx];
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const raw = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
    updateStruck(idx, raw, dragging);
  }

  const trackRefs = useRef<(HTMLDivElement | null)[]>([]);

  // Reads the outer `rows` state, not the updater's `prev`: every side effect
  // (onCommit, the announcement, the flash timer, the rAF pair) runs exactly
  // once per click, in the function body — never inside setRows' updater,
  // which React is free to invoke more than once. An append-only ledger that
  // fired its own commit callback twice would be the exact bug this
  // component exists to prevent.
  function commitLine(idx: number) {
    const row = rows[idx];
    if (!row || row.committed || row.struck <= 0) return;
    const refundAmount = row.amount * row.struck;
    const struckUnits = row.input.qty > 1 ? Math.round(row.struck * row.input.qty) : 0;
    const newRefunded = rows.reduce(
      (s, r, i) => s + (i === idx ? refundAmount : r.committed ? r.amount * r.struck : 0),
      0
    );

    setRows((prev) => {
      const cur = prev[idx];
      if (!cur || cur.committed) return prev; // idempotence guard, belt-and-braces
      return prev.map((r, i) =>
        i === idx ? { ...r, committed: true, contraOpen: reducedMotion } : r
      );
    });

    onCommit?.(row.input, refundAmount, struckUnits);
    setAnnouncement(
      `Refund recorded: −${money(refundAmount)} for ${row.input.description}. New balance ${money(subtotal - newRefunded)}.`
    );
    if (flashTimer.current) clearTimeout(flashTimer.current);
    setFlashIdx(idx);
    setBalanceFlash(true);
    flashTimer.current = setTimeout(() => {
      setFlashIdx(null);
      setBalanceFlash(false);
    }, 500);
    if (!reducedMotion) {
      // two rAFs: the first lets the browser paint the just-mounted,
      // zero-height contra row; only the second flips it open, so the
      // height transition has a real starting frame to animate from
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          setRows((cur) => cur.map((r, i) => (i === idx ? { ...r, contraOpen: true } : r)));
        });
      });
    }
  }

  function onTrackKeyDown(e: React.KeyboardEvent, idx: number) {
    const row = rows[idx];
    if (!row || row.committed) return;
    const step = stepFraction(row.input.qty, row.amount);
    let next: number | null = null;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = row.struck + step;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = row.struck - step;
        break;
      case "PageUp":
        next = row.struck + step * (row.input.qty > 1 ? 1 : 10);
        break;
      case "PageDown":
        next = row.struck - step * (row.input.qty > 1 ? 1 : 10);
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    updateStruck(idx, clamp(next, 0, 1), false);
  }

  const subtotal = rows.reduce((s, r) => s + r.amount, 0);
  const refunded = rows.reduce((s, r) => (r.committed ? s + r.amount * r.struck : s), 0);
  const balance = subtotal - refunded;

  return (
    <div className={`w-full ${className}`}>
      <style>{`
@keyframes ns-cs-contra-row{from{opacity:0}to{opacity:1}}
.ns-cs-contra{transition:height 320ms cubic-bezier(0.16,1,0.3,1), opacity 320ms cubic-bezier(0.16,1,0.3,1);overflow:hidden}
.ns-cs-rule{transition:opacity 180ms ease-out}
@keyframes ns-cs-flash{0%{opacity:.35}100%{opacity:0}}
.ns-cs-flash::after{content:"";position:absolute;inset:-2px -6px;background:var(--ns-accent);opacity:0;border-radius:6px;animation:ns-cs-flash 480ms ease-out;pointer-events:none}
@media (prefers-reduced-motion: reduce){
  .ns-cs-contra{transition:none}
  .ns-cs-rule{transition:none}
  .ns-cs-flash::after{animation:none}
}
`}</style>

      <div className="mb-2 flex items-baseline justify-between">
        <h3 className="font-mono text-sm font-medium text-foreground">{orderLabel}</h3>
        <span className="font-mono text-xs text-ns-muted">line-item ledger</span>
      </div>

      <div className="overflow-x-auto rounded-md border border-border">
        <table className="w-full border-collapse text-left">
          <caption className="sr-only">
            {orderLabel} line-item ledger with per-line partial refund. Refunds append a
            contra entry below the original line rather than editing it.
          </caption>
          <thead>
            <tr className="border-b border-border">
              <th scope="col" className="px-3 py-2 font-mono text-xs font-normal text-ns-muted">
                Item
              </th>
              <th scope="col" className="px-3 py-2 text-right font-mono text-xs font-normal text-ns-muted">
                Qty
              </th>
              <th scope="col" className="px-3 py-2 font-mono text-xs font-normal text-ns-muted">
                Amount
              </th>
              <th scope="col" className="px-3 py-2 font-mono text-xs font-normal text-ns-muted">
                <span className="sr-only">Refund action</span>
              </th>
            </tr>
          </thead>
          <tbody>
            {rows.map((row, idx) => {
              const { input, amount, struck, committed, dragging, contraOpen } = row;
              const refundAmount = amount * struck;
              const bar = barPx(amount);
              const ruleLen = struck * bar;
              const struckUnits = input.qty > 1 ? Math.round(struck * input.qty) : 0;
              const valueText =
                input.qty > 1
                  ? `${money(refundAmount)} of ${money(amount)}, ${struckUnits} of ${input.qty} units`
                  : `${money(refundAmount)} of ${money(amount)}`;
              const armed = struck > 0;
              const flashing = flashIdx === idx;

              return (
                <Fragment key={input.id}>
                  <tr
                    className={
                      "border-b border-border last:border-b-0" +
                      (committed ? " align-top" : "")
                    }
                    {...(committed
                      ? { "aria-description": "partially refunded, see contra entry below" }
                      : {})}
                  >
                    <th scope="row" className="px-3 py-2 align-top font-mono text-xs font-normal text-foreground">
                      {input.description}
                    </th>
                    <td className="px-3 py-2 text-right align-top font-mono text-xs tabular-nums text-ns-muted">
                      {input.qty}
                    </td>
                    <td className="px-3 py-2 align-top">
                      {/* the interactive slider below is entirely aria-hidden
                      inside (aria-valuetext is its single source of truth for
                      AT); this sibling restores the cell's plain reading-mode
                      data — the billed amount, struck once refunded — so the
                      row stays real, walkable table content and not just a
                      widget's private value */}
                      <span className="sr-only">
                        {money(amount)}
                        {committed ? ", struck, see contra entry below" : ""}
                      </span>
                      <div
                        ref={(el) => {
                          trackRefs.current[idx] = el;
                        }}
                        data-track={idx}
                        role="slider"
                        tabIndex={committed ? -1 : 0}
                        aria-disabled={committed || undefined}
                        aria-label={`Refund ${input.description}`}
                        aria-orientation="horizontal"
                        aria-valuemin={0}
                        aria-valuemax={100}
                        aria-valuenow={Math.round(struck * 100)}
                        aria-valuetext={valueText}
                        onKeyDown={(e) => onTrackKeyDown(e, idx)}
                        onPointerDown={(e) => {
                          if (committed) return;
                          e.preventDefault();
                          e.currentTarget.setPointerCapture(e.pointerId);
                          trackDrag(idx, e.clientX, true);
                        }}
                        onPointerMove={(e) => {
                          if (!dragging) return;
                          trackDrag(idx, e.clientX, true);
                        }}
                        onPointerUp={() => updateStruck(idx, struck, false)}
                        onPointerCancel={() => updateStruck(idx, struck, false)}
                        style={{ width: TRACK_W, height: ROW_H }}
                        className={
                          "relative select-none rounded-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background" +
                          (committed
                            ? " cursor-default"
                            : " cursor-ew-resize touch-none hover:bg-foreground/[0.035]") +
                          (flashing ? " ns-cs-flash" : "")
                        }
                      >
                        {/* the slider's own visual layers (bar, numeral, rule)
                        carry no text to assistive tech — aria-valuetext above
                        is the single source of truth for the control's value
                        — so the billed amount is restated here as real,
                        readable table-cell data, exactly once */}
                        <span className="sr-only">
                          {money(amount)}
                          {committed ? ", struck, see contra entry below" : ""}
                        </span>
                        <span
                          aria-hidden
                          className="absolute left-0 top-1/2 -translate-y-1/2 rounded-full bg-foreground/[0.07]"
                          style={{ width: bar, height: 3 }}
                        />
                        <span
                          aria-hidden
                          className={
                            "absolute left-0 top-1/2 flex h-full -translate-y-1/2 items-center font-mono text-sm tabular-nums" +
                            (committed
                              ? " text-foreground line-through decoration-1 decoration-foreground/70"
                              : " text-foreground")
                          }
                        >
                          {money(amount)}
                        </span>
                        {armed ? (
                          <svg
                            aria-hidden
                            className="ns-cs-rule pointer-events-none absolute inset-0"
                            width={TRACK_W}
                            height={ROW_H}
                          >
                            <line
                              x1={0}
                              y1={ROW_H / 2}
                              x2={Math.max(1, ruleLen)}
                              y2={ROW_H / 2}
                              stroke="var(--foreground)"
                              strokeWidth={1.5}
                              strokeLinecap="round"
                              strokeDasharray={committed ? undefined : "4 3"}
                            />
                          </svg>
                        ) : null}
                      </div>
                    </td>
                    <td className="px-3 py-2 align-top text-right">
                      <button
                        type="button"
                        data-commit={idx}
                        onClick={() => commitLine(idx)}
                        className="rounded-sm border border-border px-2.5 py-1 font-mono text-xs text-foreground transition-colors hover:bg-foreground/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent disabled:pointer-events-none disabled:opacity-40"
                        disabled={committed || !armed}
                      >
                        {committed
                          ? "Refunded"
                          : armed
                            ? `Refund ${money(refundAmount)}`
                            : "Refund"}
                      </button>
                    </td>
                  </tr>

                  {committed ? (
                    <tr key={`${input.id}-contra`} data-contra={idx} className="border-b border-border last:border-b-0">
                      <td colSpan={4} className="p-0">
                        <div
                          className="ns-cs-contra"
                          style={{ height: contraOpen ? ROW_H : 0, opacity: contraOpen ? 1 : 0 }}
                        >
                          <div
                            className="flex items-center gap-3 px-3 font-mono text-xs"
                            style={{ height: ROW_H, animation: contraOpen ? "ns-cs-contra-row 200ms ease-out" : "none" }}
                          >
                            <span className="w-0 flex-1 truncate text-ns-muted">
                              Contra — {input.description}
                            </span>
                            <span className="shrink-0 text-right text-ns-muted">
                              {input.qty > 1 ? `−${struckUnits} of ${input.qty} units` : ""}
                            </span>
                            <span
                              aria-hidden
                              className="relative shrink-0 rounded-full bg-foreground/[0.14]"
                              style={{ width: ruleLen, height: 3 }}
                            />
                            <span className="shrink-0 tabular-nums text-foreground">
                              −{money(refundAmount)}
                            </span>
                          </div>
                        </div>
                      </td>
                    </tr>
                  ) : null}
                </Fragment>
              );
            })}
          </tbody>
          <tfoot>
            <tr className="border-t border-border">
              <th scope="row" colSpan={3} className="px-3 py-2 text-right font-mono text-xs font-normal text-ns-muted">
                Subtotal
              </th>
              <td className="px-3 py-2 text-right font-mono text-xs tabular-nums text-ns-muted">
                {money(subtotal)}
              </td>
            </tr>
            <tr>
              <th scope="row" colSpan={3} className="px-3 py-2 text-right font-mono text-xs font-normal text-ns-muted">
                Refunded
              </th>
              <td className="px-3 py-2 text-right font-mono text-xs tabular-nums text-ns-muted">
                −{money(refunded)}
              </td>
            </tr>
            <tr className="border-t border-border">
              <th scope="row" colSpan={3} className="px-3 py-2 text-right font-mono text-sm font-medium text-foreground">
                Balance
              </th>
              <td className="px-3 py-2 text-right align-middle">
                <span
                  className={"relative inline-block font-mono text-sm font-medium tabular-nums text-foreground" + (balanceFlash ? " ns-cs-flash" : "")}
                >
                  {money(balance)}
                </span>
              </td>
            </tr>
          </tfoot>
        </table>
      </div>

      <p role="status" aria-live="polite" className="sr-only">
        {announcement}
      </p>
    </div>
  );
}

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

Use when recording a partial refund or credit against a real order line-item ledger where the original entry must survive unedited and the correction has to read as a signed contra row appended beneath it — support tooling, merchant billing dashboards, anywhere auditability is the deliverable. Reach for approval-inline-diff instead when the job is a human reviewing and editing an agent's proposed arguments before a one-shot approve/deny: that component previews a change AGAINST the original and never keeps both values live at once, where contra-strike never previews anything — it only ever appends a new row after the old one, and the old one's figures never change.

Install

npx shadcn add https://design.helpmarq.com/r/contra-strike.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

Renders an order's line-item ledger as a real semantic <table> (thead/tbody/tfoot, th scope=row on each item name, th scope=col on each header) where a partial refund is recorded the way double-entry bookkeeping actually records one: the original row's DOM node and figures are never edited or removed, ever — the correction always materialises as a brand-new signed contra row appended directly beneath it. Each line item ({id, description, qty, unitPrice}) renders its amount as a fixed-width bar on ONE shared money-per-px scale derived from the largest line in the table, so every row's bar is visually comparable. Dragging anywhere on that bar (a real role=slider region, aria-label 'Refund <description>', aria-valuetext formatted exactly like '$25.00 of $75.00, 2 of 6 units') arms a single governing scalar, the struck fraction s (0-1): on a quantity line (qty>1) the drag snaps to whole-unit boundaries — s can only land on k/qty, so a partial refund can never claim a fractional unit — while a flat line with qty=1 snaps to the cent instead. That same s simultaneously drives the live refund figure, the length of an SVG line drawn across the amount bar (dash-array '4 3' — genuinely dashed — for as long as the row is only armed, never committed, so a mid-drag selection can never be mistaken for an already-settled correction), and, the instant a real 'Refund $x' button is pressed (its accessible name literally names the amount, and it stays disabled at s=0 — there is nothing to name yet), the width of the new contra row's own bar on the identical shared scale. Committing flips that same SVG rule from dashed to a solid stroke, height-expands the contra row from 0 (two rAF frames apart so the browser actually paints the zero-height frame first, then the real one, or the transition never plays), and re-derives the ledger's Subtotal/Refunded/Balance footer with a brief settle highlight — nothing here is ever mutable after commit; there is no undo, no revert, no toast, because the entire trust story of a ledger is that nothing already booked can un-happen. Once committed, the struck row's slider becomes non-interactive (aria-disabled, removed from tab order) — its qty/description/amount text is completely untouched, decorated with a permanent line-through on the amount (decoration, never deletion, still fully legible) and carries aria-description 'partially refunded, see contra entry below'. The contra row itself reads 'Contra — <description>' with a leading minus on both its unit count and its Geist Mono figure, and a live region (role=status, rendered unconditionally as a sibling of the table, never inside it) announces 'Refund recorded: -$X for <description>. New balance $Y.' the moment it lands. A consumer can pre-seed a line as already armed via defaultStruck (the draft strike renders at rest, same reasoning as approval-inline-diff's initialValues) or as already refunded via committedStruck, mounting straight into the settled state — contra row open, rule solid — with no animation, exactly like replaying a history feed. Keyboard: each slider takes Tab, ArrowLeft/Right (and Up/Down) step by one unit or one cent, PageUp/PageDown take a bigger stride, Home/End jump to 0%/100%; sliders use outline-none paired only with focus-visible:ring-2 focus-visible:ring-ns-accent (never focus-visible:outline-* on the same element), buttons use focus-visible:outline-2 focus-visible:outline-ns-accent with no base outline-none. --ns-accent appears only as those focus rings and a sub-second settle flash tied directly to the commit click — never as a resting fill; the commit button is outlined, not accent-filled. Money is formatted via Intl.NumberFormat with an explicit locale/currency (both overridable props, defaulting en-US/USD) so server and client render identical digits. Under prefers-reduced-motion the dashed-rule transition, the contra row's height-expand, and the settle flash are all skipped outright — the rule, the row, and the new balance simply appear in their final state, fully legible and operable either way. Pure DOM + inline SVG + CSS, no canvas, tokens only (--background --foreground --ns-muted --border --ns-accent), correct in both themes.

Props

PropTypeDefaultDescription
orderLabel?string"Order"heading shown above the ledger, e.g. "Order #4471"
linesContraStrikeLine[]—the order's line items
currency?string"USD"ISO currency code passed to Intl.NumberFormat
locale?string"en-US"BCP 47 locale passed to Intl.NumberFormat — always explicit, never the runtime default, so server and client render identical digits
onCommit?(line: ContraStrikeLine, refundAmount: number, struckUnits: number) => void—fired once per line the moment its contra row is committed
className?string—extra classes merged onto the rendered root element