| Asset | Channel | Owner |
|---|---|---|
| Old FAQ Post , proposed removal | Blog | dana-w |
| Recap Thread , proposed removal | Social | marcus-t |
| Trail Guide | Blog | priya-s |
| Gear Care Email , proposed addition | Email | dana-w |
Astro UI
Diff Table.
AI-proposed table edits you include or skip, then apply.
DataComponent1 dependency Beautiful UI, MIT
Install
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import DiffTable from "../components/viewership/diff-table/DiffTable.astro"; --- <DiffTable />
Props 6
| Prop | Type | Default | Description |
|---|---|---|---|
columns | { key: string; label: string; width?: string; kind?: "title" | "tag" | "text" }[] | · | Column order and headers. The first column names the row. |
rows | { key: string; cells: Record<string, string>; change?: "remove" | "add" }[] | · | Table data. Rows with `change` are proposed edits. |
tagTones | Record<string, "accent" | "neutral" | "green" | "orange" | "red"> | {} | Dot color per tag value. |
labels | Partial<DiffTableLabels> | · | Text overrides: title, hint, removal(s), addition(s), apply, applied, includeRemove, includeAdd, removedRow, addedRow. |
autoplay | boolean | true | Play when scrolled into view. Reduced motion and no-JS show the final diff. |
loop | boolean | false | Replay after a 4s hold until interaction. |
Events
| Event | Detail | When |
|---|---|---|
vs-diff-toggle | CustomEvent<{ key: string; included: boolean }> | Fires when a change is included or skipped. |
vs-diff-apply | CustomEvent<{ removed: string[]; added: string[]; skipped: string[] }> | Fires on Apply with row keys by kind. The table only shows a confirmation. |
Source 1 file
---
/**
* DiffTable: AI-proposed edits sweeping through tabular data.
*
* Ported from Beautiful UI `components/primitives/DiffTable.tsx`
* (MIT, © 2026 Shane Levine).
*
* The proposed edit plays once when the table scrolls into view and rests on
* the completed diff: removals turn red and strike through, then added rows
* grow in with include checkboxes and a summary footer. Clicking a changed
* row toggles it. "Apply" reports the included edits; the table itself
* changes nothing. Without JS or with reduced motion, the final diff renders.
*
* Events (bubbling, on the root):
* vs-diff-toggle detail: { key, included }
* vs-diff-apply detail: { removed: string[], added: string[], skipped: string[] }
*
* @example
* <DiffTable columns={[{ key: "asset", label: "Asset", kind: "title" }]} rows={[{ key: "a", cells: { asset: "Gear Guide" }, change: "add" }]} />
*/
import type { HTMLAttributes } from "astro/types";
import Button from "../button/Button.astro";
type Tone = "accent" | "neutral" | "green" | "orange" | "red";
export interface DiffColumn {
key: string;
label: string;
/** CSS width for the column, e.g. "34%". */
width?: string;
/** "title" = strong first column, "tag" = dot pill, "text" = muted text (default). */
kind?: "title" | "tag" | "text";
}
export interface DiffRow {
key: string;
cells: Record<string, string>;
/** The proposed edit for this row, if any. */
change?: "remove" | "add";
}
export interface DiffTableLabels {
title: string;
hint: string;
removal: string;
removals: string;
addition: string;
additions: string;
/** {n} is replaced. */
apply: string;
applyPlural: string;
applied: string;
appliedPlural: string;
/** Checkbox names; {name} is the row's first cell. */
includeRemove: string;
includeAdd: string;
/** Screen-reader suffix on changed rows. */
removedRow: string;
addedRow: string;
}
type Props = HTMLAttributes<"div"> & {
columns: DiffColumn[];
rows: DiffRow[];
/** Dot color per tag value (for "tag" columns). */
tagTones?: Record<string, Tone>;
labels?: Partial<DiffTableLabels>;
/** Play the sweep when scrolled into view. */
autoplay?: boolean;
/** Replay after a hold, until someone interacts. */
loop?: boolean;
};
const { columns = [], rows = [], tagTones = {}, labels, autoplay = true, loop = false, class: className, ...rest } = Astro.props;
const l: DiffTableLabels = {
title: "Proposed changes",
hint: "Click changed rows to toggle",
removal: "removal",
removals: "removals",
addition: "addition",
additions: "additions",
apply: "Apply {n} change",
applyPlural: "Apply {n} changes",
applied: "{n} edit applied",
appliedPlural: "{n} edits applied",
includeRemove: "Include: remove {name}",
includeAdd: "Include: add {name}",
removedRow: "proposed removal",
addedRow: "proposed addition",
...labels,
};
const uid = `vs-diff-${Math.random().toString(36).slice(2, 8)}`;
const TONE: Record<Tone, string> = {
accent: "var(--vs-accent)",
neutral: "var(--vs-ink-3)",
green: "var(--vs-green)",
orange: "var(--vs-orange)",
red: "var(--vs-red)",
};
const DEFAULT_WIDTHS = ["34%", "30%", "36%"];
const removals = rows.filter((r) => r.change === "remove").length;
const additions = rows.filter((r) => r.change === "add").length;
const total = removals + additions;
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many).replace("{n}", String(n));
const summary = `${removals} ${removals === 1 ? l.removal : l.removals} · ${additions} ${additions === 1 ? l.addition : l.additions}`;
const lastCol = columns.length - 1;
const checkPath = '<path d="M20 6L9 17l-5-5" />';
---
<div
class:list={["vs-diff-table", className]}
data-tinted
data-settled
data-autoplay={autoplay ? "" : undefined}
data-loop={loop ? "" : undefined}
data-labels={JSON.stringify({
removal: l.removal,
removals: l.removals,
addition: l.addition,
additions: l.additions,
apply: l.apply,
applyPlural: l.applyPlural,
applied: l.applied,
appliedPlural: l.appliedPlural,
})}
{...rest}
>
<div class="card">
<div class="bar">
<span class="title" id={`${uid}-title`}>{l.title}</span>
<span class="hint">{l.hint}</span>
</div>
<table class="dt-table" aria-labelledby={`${uid}-title`}>
<colgroup>
{columns.map((c, i) => <col style={`width: ${c.width ?? DEFAULT_WIDTHS[i] ?? "auto"}`} />)}
</colgroup>
<thead>
<tr>
{columns.map((c) => <th scope="col" class="th">{c.label}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const name = row.cells[columns[0]?.key ?? ""] ?? row.key;
return (
<tr class="row" data-row={row.key} data-change={row.change}>
{columns.map((c, ci) => {
const value = row.cells[c.key] ?? "";
const kind = c.kind ?? (ci === 0 ? "title" : "text");
const content = (
<div class="pad">
{kind === "tag" ? (
<span class="tag">
<span class="tag-dot" style={`background: ${TONE[tagTones[value] ?? "neutral"]}`}></span>
<span class="tag-label">{value}</span>
</span>
) : ci === lastCol && row.change ? (
<span class="end">
<span class="truncate">{value}</span>
<label class="mark-wrap">
<input
class="native"
type="checkbox"
checked
aria-label={(row.change === "remove" ? l.includeRemove : l.includeAdd).replace("{name}", name)}
data-include={row.key}
/>
<span class:list={["mark", `mark--${row.change}`]} aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
</span>
</label>
</span>
) : (
<span class="truncate">
{value}
{ci === 0 && row.change && <span class="vh">, {row.change === "remove" ? l.removedRow : l.addedRow}</span>}
</span>
)}
</div>
);
return (
<td class:list={["cell", `cell--${kind}`, ci === lastCol && "cell--last"]}>
{row.change === "add" ? (
<div class="dt-grow">
<div class="grow-clip">{content}</div>
</div>
) : (
content
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
<div class="footer">
<div class="pending" data-pending>
<span class="summary" aria-live="polite" data-summary>{summary}</span>
<Button variant="accent" size="sm" class="apply" disabled={total === 0} data-apply>
{plural(total, l.apply, l.applyPlural)}
</Button>
</div>
<span class="applied" role="status" tabindex="-1" hidden data-applied>
<span class="applied-check" aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
</span>
<span data-applied-text></span>
</span>
</div>
</div>
</div>
<script>
const STAGE_DELAYS = [180, 260];
const LOOP_HOLD = 4000;
interface Labels {
removal: string;
removals: string;
addition: string;
additions: string;
apply: string;
applyPlural: string;
applied: string;
appliedPlural: string;
}
function init(root: HTMLElement) {
const l = JSON.parse(root.dataset.labels || "{}") as Labels;
const rows = [...root.querySelectorAll<HTMLTableRowElement>("tr[data-change]")];
const boxes = [...root.querySelectorAll<HTMLInputElement>("[data-include]")];
const summary = root.querySelector<HTMLElement>("[data-summary]")!;
const apply = root.querySelector<HTMLButtonElement>("[data-apply]")!;
const pending = root.querySelector<HTMLElement>("[data-pending]")!;
const applied = root.querySelector<HTMLElement>("[data-applied]")!;
const appliedText = root.querySelector<HTMLElement>("[data-applied-text]")!;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const loop = root.hasAttribute("data-loop");
let stage = 2;
let accepted = false;
let touched = false;
let visible = false;
let timer: number | undefined;
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many).replace("{n}", String(n));
const changeOf = (box: HTMLInputElement) => box.closest<HTMLElement>("tr")?.dataset.change;
const counts = () => {
const on = boxes.filter((b) => b.checked);
return {
removals: on.filter((b) => changeOf(b) === "remove").length,
additions: on.filter((b) => changeOf(b) === "add").length,
};
};
const renderSummary = () => {
const { removals, additions } = counts();
const total = removals + additions;
summary.textContent = `${removals} ${removals === 1 ? l.removal : l.removals} · ${additions} ${additions === 1 ? l.addition : l.additions}`;
apply.textContent = plural(total, l.apply, l.applyPlural);
apply.disabled = total === 0;
};
const setStage = (next: number) => {
stage = next;
root.toggleAttribute("data-tinted", stage >= 1);
root.toggleAttribute("data-settled", stage >= 2);
// Changed rows are only operable once the diff has settled.
boxes.forEach((b) => (b.disabled = stage < 2 || accepted));
};
const toggle = (box: HTMLInputElement) => {
touched = true;
renderSummary();
root.dispatchEvent(
new CustomEvent("vs-diff-toggle", { bubbles: true, detail: { key: box.dataset.include, included: box.checked } }),
);
};
boxes.forEach((box) => box.addEventListener("change", () => toggle(box)));
// The whole changed row is a pointer target for its checkbox.
rows.forEach((row) => {
const box = row.querySelector<HTMLInputElement>("[data-include]");
if (!box) return;
row.addEventListener("click", (event) => {
if (stage < 2 || accepted || (event.target as Element).closest("label, input")) return;
box.checked = !box.checked;
toggle(box);
});
});
apply.addEventListener("click", () => {
if (accepted) return;
accepted = true;
touched = true;
window.clearTimeout(timer);
const { removals, additions } = counts();
appliedText.textContent = plural(removals + additions, l.applied, l.appliedPlural);
pending.hidden = true;
applied.hidden = false;
root.setAttribute("data-accepted", "");
setStage(2);
applied.focus();
const key = (b: HTMLInputElement) => b.dataset.include ?? "";
root.dispatchEvent(
new CustomEvent("vs-diff-apply", {
bubbles: true,
detail: {
removed: boxes.filter((b) => b.checked && changeOf(b) === "remove").map(key),
added: boxes.filter((b) => b.checked && changeOf(b) === "add").map(key),
skipped: boxes.filter((b) => !b.checked).map(key),
},
}),
);
});
const scheduleLoop = () => {
window.clearTimeout(timer);
if (!loop || touched || accepted || !visible || stage < 2) return;
timer = window.setTimeout(() => {
if (root.matches(":hover") || root.contains(document.activeElement)) return scheduleLoop();
play();
}, LOOP_HOLD);
};
const play = () => {
window.clearTimeout(timer);
boxes.forEach((b) => (b.checked = true));
renderSummary();
setStage(0);
let i = 0;
const step = () => {
setStage(stage + 1);
i += 1;
if (i < STAGE_DELAYS.length) timer = window.setTimeout(step, STAGE_DELAYS[i]);
else scheduleLoop();
};
timer = window.setTimeout(step, STAGE_DELAYS[0]);
};
renderSummary();
setStage(2);
if (reduce || !root.hasAttribute("data-autoplay")) return;
// Hold the plain table until it is seen, then sweep the edit through it.
setStage(0);
let started = false;
const io = new IntersectionObserver(
([entry]) => {
if (!root.isConnected) return teardown();
visible = Boolean(entry?.isIntersecting);
if (!visible) {
if (stage >= 2) window.clearTimeout(timer);
return;
}
if (!started) {
started = true;
play();
} else scheduleLoop();
},
{ threshold: 0.3 },
);
io.observe(root);
function teardown() {
window.clearTimeout(timer);
io.disconnect();
}
document.addEventListener("astro:before-swap", teardown, { once: true });
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-diff-table:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>
<style>
.vs-diff-table {
width: 100%;
max-width: 380px;
}
.vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.card {
position: relative;
overflow: hidden;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--vs-line);
}
.title {
font-size: 12.5px;
font-weight: 500;
color: var(--vs-ink);
}
.hint {
font-size: 11px;
color: var(--vs-ink-3);
visibility: hidden;
}
[data-settled]:not([data-accepted]) .hint {
visibility: visible;
}
/* ── table ── */
.dt-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
text-align: left;
}
.th {
padding: 10px 12px;
border-bottom: 1px solid var(--vs-line);
font-size: 12px;
font-weight: 500;
color: var(--vs-ink-3);
}
.row {
border-bottom: 1px solid var(--vs-line);
transition:
background-color 150ms ease-out,
filter 150ms ease-out;
}
.row:last-child {
border-bottom: 0;
}
.cell {
padding: 0;
vertical-align: middle;
}
.pad {
padding: 10px 12px;
}
.truncate {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell--title {
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--vs-ink);
transition: color 200ms ease-out;
}
.cell--text {
font-size: 12.5px;
color: var(--vs-ink-2);
transition: color 200ms ease-out;
}
.tag {
display: inline-flex;
height: 22px;
align-items: center;
gap: 6px;
padding: 0 8px;
border-radius: var(--vs-radius-pill);
background: var(--vs-inset);
box-shadow: var(--vs-shadow-hairline);
font-size: 11.5px;
font-weight: 500;
transition: opacity 200ms ease-out;
}
.tag-dot {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
}
.tag-label {
color: var(--vs-ink-2);
white-space: nowrap;
}
.end {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* removals: red tint + strike, once the sweep reaches them */
[data-tinted] .row[data-change="remove"]:has(.native:checked) {
background: var(--vs-red-tint);
}
[data-tinted] .row[data-change="remove"]:has(.native:checked) :is(.cell--title, .cell--text) {
color: var(--vs-red);
}
[data-tinted] .row[data-change="remove"]:has(.native:checked) .tag {
opacity: 0.55;
}
[data-tinted] .row[data-change="remove"]:has(.native:checked) .cell--last .truncate {
text-decoration-line: line-through;
text-decoration-color: color-mix(in srgb, var(--vs-red) 50%, transparent);
}
/* additions: grow in once settled; green while included */
.row[data-change="add"] .tag {
background: var(--vs-surface);
}
.row[data-change="add"] :is(.cell--title, .cell--text) {
color: var(--vs-ink-3);
}
.row[data-change="add"]:has(.native:checked) {
background: var(--vs-green-tint);
}
.row[data-change="add"]:has(.native:checked) :is(.cell--title, .cell--text) {
color: var(--vs-green);
}
.dt-grow {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
transition:
grid-template-rows 200ms var(--vs-ease-out),
opacity 200ms var(--vs-ease-out),
visibility 0s 200ms;
}
[data-settled] .dt-grow {
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition:
grid-template-rows 200ms var(--vs-ease-out),
opacity 200ms var(--vs-ease-out);
}
.grow-clip {
min-height: 0;
overflow: hidden;
}
/* changed rows are the control once the diff settles */
[data-settled]:not([data-accepted]) .row[data-change] {
cursor: pointer;
}
[data-settled]:not([data-accepted]) .row[data-change]:hover {
filter: brightness(0.985);
}
/* ── include mark (a native checkbox) ── */
.mark-wrap {
position: relative;
display: flex;
width: 24px;
height: 24px;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin: -3px;
cursor: pointer;
}
.row[data-change="remove"] .mark-wrap {
visibility: hidden;
}
[data-settled] .row[data-change="remove"] .mark-wrap {
visibility: visible;
}
.native {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.native:disabled {
cursor: default;
}
.mark {
display: flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: 5px;
background: var(--vs-inset);
box-shadow: var(--vs-shadow-hairline);
color: transparent;
transform: scale(0.92);
transition:
background-color 150ms ease-out,
color 150ms ease-out,
transform 150ms ease-out;
}
.native:checked + .mark {
color: var(--vs-surface);
box-shadow: none;
transform: scale(1);
}
.native:checked + .mark--remove {
background: var(--vs-red);
}
.native:checked + .mark--add {
background: var(--vs-green);
}
.native:focus-visible + .mark {
box-shadow: var(--vs-focus-ring);
}
/* ── footer ── */
.footer {
display: none;
min-height: 44px;
align-items: center;
padding: 10px;
border-top: 1px solid var(--vs-line);
animation: vs-fade-up 180ms var(--vs-ease-out) both;
}
[data-settled] .footer {
display: flex;
}
.pending {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.pending[hidden] {
display: none;
}
.summary {
font-size: 11.5px;
font-variant-numeric: tabular-nums;
color: var(--vs-ink-3);
}
.pending :global(.apply) {
font-size: 12px;
}
.applied {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 4px;
border-radius: var(--vs-radius-pill);
background: var(--vs-green-tint);
color: var(--vs-green);
font-size: 12.5px;
font-weight: 500;
outline: none;
animation: vs-pop-in 180ms var(--vs-ease-out) both;
}
.applied[hidden] {
display: none;
}
.applied-check {
display: flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--vs-green);
color: var(--vs-surface);
}
@media (prefers-reduced-motion: reduce) {
.row,
.cell--title,
.cell--text,
.tag,
.dt-grow,
[data-settled] .dt-grow,
.mark {
transition: none;
}
.footer,
.applied {
animation: none;
}
}
@media (forced-colors: active) {
.native:checked + .mark {
background: Highlight;
color: HighlightText;
}
[data-tinted] .row[data-change="remove"]:has(.native:checked) .cell--title {
text-decoration-line: line-through;
}
}
</style> Ported from Beautiful UI, MIT. Notices.