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.
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.jsonSource
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>
);
}
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
| Prop | Type | Default | Description |
|---|---|---|---|
| orderLabel? | string | "Order" | heading shown above the ledger, e.g. "Order #4471" |
| lines | ContraStrikeLine[] | — | 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 |