# Change Review
A diff review card where every hunk is kept or discarded before the kept changes apply.
## 1. Setup
```tsx
// npm install framer-motion
```
File: components-workspace/change-review/index.tsx · 'use client' · export default function ChangeReview(props: ChangeReviewProps)
```tsx
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
import type * as React from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { FoldVertical } from '@/app/_icons/icons/fold-vertical'
import { UnfoldVertical } from '@/app/_icons/icons/unfold-vertical'
import { ChevronDown } from '@/app/_icons/icons/chevron-down'
import { Check } from '@/app/_icons/icons/check'
import { X } from '@/app/_icons/icons/x'
import { CodeDuotone } from '@/app/_icons/icons/code-duotone'
import { FileCodeDuotone } from '@/app/_icons/icons/file-code-duotone'
import { CircleCheckDuotone } from '@/app/_icons/icons/circle-check-duotone'
import { CircleMinusDuotone } from '@/app/_icons/icons/circle-minus-duotone'
```
The `@/app/_icons/icons/*` imports are this kit's icon set (props: `size`, `weight`, `className`). Without it, map each import to the matching glyph of any icon set with the same props. Classes need Tailwind CSS v4 (`bg-(--var)`, `@container`, `pointer-coarse:`); the `dark:` variants follow whatever dark mode your project configures.
## 2. Constants
```tsx
'use client'
// Every number the component animates or measures with: ms unless named, sizes in px.
export type Tuning = {
hoverMs: number
pressScale: number
stateMs: number
enterMs: number
enterY: number
staggerMs: number
exitMs: number
collapseMs: number
foldMs: number
lightMs: number
cardMaxW: number
cardRadius: number
lineH: number
gutterW: number
fadeW: number
replayDelayMs: number
glyphW: number
decisionW: number
dimPct: number
}
export const TUNING: Tuning = {
hoverMs: 150,
pressScale: 0.97,
stateMs: 200,
enterMs: 280,
enterY: 6,
staggerMs: 40,
exitMs: 120,
collapseMs: 280,
foldMs: 280,
lightMs: 600,
cardMaxW: 560,
cardRadius: 24,
lineH: 20,
gutterW: 28,
fadeW: 24,
// Must equal exitMs + collapseMs, so Replay shows once the receipt has settled.
replayDelayMs: 400,
glyphW: 16,
// Holds the Keep / Discard pair's width once decided, so the reason never reflows.
decisionW: 176,
dimPct: 40,
}
const EASE = [0.23, 1, 0.32, 1] as const
export type DiffLineKind = 'ctx' | 'add' | 'del' | 'fold'
// fold: text is the hidden lines joined by newlines; oldNo / newNo number the first.
export type DiffLine = {
kind: DiffLineKind
text: string
oldNo?: number
newNo?: number
marks?: [number, number][]
count?: number
}
export type DiffHunk = { id: string; reason: string; lines: DiffLine[] }
export type DiffFile = { path: string; hunks: DiffHunk[] }
export type HunkDecision = 'keep' | 'discard'
type ChangeReviewProps = {
title?: string
checkSummary?: string
files?: DiffFile[]
onDecide?: (hunkId: string, decision: HunkDecision | null) => void
onApply?: (keptIds: string[]) => void
onUndo?: () => void
className?: string
tuning?: Partial<Tuning>
}
function marksOf(text: string, ...parts: string[]): [number, number][] {
return parts.map((p) => {
const at = text.indexOf(p)
return [at, at + p.length]
})
}
const A_DEL = String.raw` const d = new Date(input.replace(/Z|[+-]\d\d:\d\d$/, ''))`
const B_DEL = " if (!input) throw new Error('empty date')"
const B_ADD = ' if (!input.trim()) return null'
const DEMO_FILES: DiffFile[] = [
{
path: 'src/date.ts',
hunks: [
{
id: 'offset',
reason: 'Keep the time zone offset when parsing ISO strings',
lines: [
{ kind: 'ctx', text: 'export function parseDate(input: string) {', oldNo: 1, newNo: 1 },
{ kind: 'del', text: A_DEL, oldNo: 2, marks: marksOf(A_DEL, String.raw`.replace(/Z|[+-]\d\d:\d\d$/, '')`) },
{ kind: 'add', text: ' const d = new Date(input)', newNo: 2 },
{ kind: 'ctx', text: ' if (Number.isNaN(d.getTime())) return null', oldNo: 3, newNo: 3 },
],
},
{
id: 'empty',
reason: 'Return null for an empty string instead of throwing',
lines: [
{
kind: 'fold',
oldNo: 4,
newNo: 4,
count: 12,
text: [
' return d',
'}',
'',
'export function toDay(date: Date) {',
' return date.toISOString().slice(0, 10)',
'}',
'',
'export function isSameDay(a: Date, b: Date) {',
' return toDay(a) === toDay(b)',
'}',
'',
'// Accepts YYYY-MM-DD and returns midnight UTC.',
].join('\n'),
},
{ kind: 'ctx', text: 'export function parseDay(input: string) {', oldNo: 16, newNo: 16 },
{ kind: 'del', text: B_DEL, oldNo: 17, marks: marksOf(B_DEL, "throw new Error('empty date')") },
{ kind: 'add', text: B_ADD, newNo: 17, marks: marksOf(B_ADD, '.trim()', 'return null') },
{ kind: 'ctx', text: " return parseDate(input + 'T00:00:00Z')", oldNo: 18, newNo: 18 },
],
},
],
},
{
path: 'src/date.test.ts',
hunks: [
{
id: 'test',
reason: 'Add a test for a +09:00 offset',
lines: [
{
kind: 'ctx',
text: " it('returns null for an invalid date', () => expect(parseDate('nope')).toBeNull())",
oldNo: 14,
newNo: 14,
},
{ kind: 'add', text: " it('keeps a +09:00 offset', () => {", newNo: 15 },
{ kind: 'add', text: " const d = parseDate('2026-03-01T09:00:00+09:00')", newNo: 16 },
{ kind: 'add', text: " expect(d?.toISOString()).toBe('2026-03-01T00:00:00.000Z')", newNo: 17 },
{ kind: 'add', text: ' })', newNo: 18 },
{ kind: 'ctx', text: '})', oldNo: 15, newNo: 19 },
],
},
],
},
]
const DEFAULT_TITLE = 'Fix the time zone offset in parseDate'
const DEFAULT_CHECK = 'Tests pass · 58 of 58'
const BLOCKED = 'Review every change first'
const NBSP = String.fromCharCode(160)
function plural(n: number, one: string, many = `${one}s`) {
return `${n} ${n === 1 ? one : many}`
}
function countLines(lines: DiffLine[]) {
let add = 0
let del = 0
for (const l of lines) {
if (l.kind === 'add') add++
else if (l.kind === 'del') del++
}
return { add, del }
}
function countFile(file: DiffFile) {
return countLines(file.hunks.flatMap((h) => h.lines))
}
function splitLeading(lines: DiffLine[]) {
let i = 0
while (i < lines.length && lines[i].kind === 'fold') i++
return { lead: lines.slice(0, i), body: lines.slice(i) }
}
function unfold(line: DiffLine): DiffLine[] {
const rows = line.text.split('\n')
return rows.map((text, i) => ({
kind: 'ctx',
text,
oldNo: line.oldNo === undefined ? undefined : line.oldNo + i,
newNo: line.newNo === undefined ? undefined : line.newNo + i,
}))
}
function foldCount(line: DiffLine) {
return line.count ?? line.text.split('\n').length
}
function maxLineNo(files: DiffFile[]) {
let max = 0
for (const f of files) {
for (const h of f.hunks) {
for (const l of h.lines) {
const span = l.kind === 'fold' ? foldCount(l) - 1 : 0
max = Math.max(max, (l.oldNo ?? 0) + span, (l.newNo ?? 0) + span)
}
}
}
return max
}
type TokenKind = 'keyword' | 'string' | 'number' | 'comment' | 'ident' | 'fn' | 'punct'
type Token = { text: string; kind: TokenKind }
type ScanState = { block: boolean; template: boolean }
const KEYWORDS = new Set([
'abstract', 'any', 'as', 'async', 'await', 'bigint', 'boolean', 'break', 'case', 'catch',
'class', 'const', 'continue', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export',
'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer',
'instanceof', 'interface', 'is', 'keyof', 'let', 'never', 'new', 'number', 'object', 'of',
'private', 'protected', 'public', 'readonly', 'return', 'satisfies', 'static', 'string',
'super', 'switch', 'symbol', 'this', 'throw', 'try', 'type', 'typeof', 'unknown', 'var',
'void', 'while', 'yield',
])
const LITERALS = new Set(['false', 'Infinity', 'NaN', 'null', 'true', 'undefined'])
function closingQuote(line: string, from: number, quote: string) {
for (let i = from; i < line.length; i += 1) {
if (line[i] === '\\') {
i += 1
continue
}
if (line[i] === quote) return i
}
return -1
}
function scanLine(line: string, state: ScanState, out: Token[]) {
let i = 0
while (i < line.length) {
if (state.block || state.template) {
const end = state.block ? line.indexOf('*/', i) : closingQuote(line, i, '`')
const kind: TokenKind = state.block ? 'comment' : 'string'
if (end === -1) {
out.push({ text: line.slice(i), kind })
return
}
const stop = state.block ? end + 2 : end + 1
out.push({ text: line.slice(i, stop), kind })
state.block = false
state.template = false
i = stop
continue
}
const ch = line[i]
if (ch === '/' && line[i + 1] === '/') {
out.push({ text: line.slice(i), kind: 'comment' })
return
}
if (ch === '/' && line[i + 1] === '*') {
state.block = true
out.push({ text: '/*', kind: 'comment' })
i += 2
continue
}
if (ch === '"' || ch === "'" || ch === '`') {
const end = closingQuote(line, i + 1, ch)
if (end === -1) {
out.push({ text: line.slice(i), kind: 'string' })
if (ch === '`') state.template = true
return
}
out.push({ text: line.slice(i, end + 1), kind: 'string' })
i = end + 1
continue
}
if (/[0-9]/.test(ch)) {
let j = i + 1
while (j < line.length && /[0-9a-fA-FxXoObBn._]/.test(line[j])) j += 1
out.push({ text: line.slice(i, j), kind: 'number' })
i = j
continue
}
if (/[A-Za-z_$]/.test(ch)) {
let j = i + 1
while (j < line.length && /[A-Za-z0-9_$]/.test(line[j])) j += 1
const word = line.slice(i, j)
let kind: TokenKind = 'ident'
if (KEYWORDS.has(word)) kind = 'keyword'
else if (LITERALS.has(word)) kind = 'number'
else if (line[j] === '(' && /^[a-z_$]/.test(word)) kind = 'fn'
out.push({ text: word, kind })
i = j
continue
}
let j = i + 1
while (j < line.length && !/[A-Za-z0-9_$"'`/]/.test(line[j])) j += 1
out.push({ text: line.slice(i, j), kind: 'punct' })
i = j
}
}
// Lines are scanned one by one, so a multi-line string or comment colours only its first.
function tokenizeLine(line: string): Token[] {
const out: Token[] = []
scanLine(line, { block: false, template: false }, out)
return out
}
const TOKEN_CLASS: Record<TokenKind, string> = {
keyword: 'text-(color:--cr-ai)',
string: 'text-(color:--cr-success)',
number: 'text-(color:--cr-attention)',
comment: 'italic text-(color:--cr-muted)',
ident: 'text-(color:--cr-text)',
fn: 'font-semibold text-(color:--cr-text)',
punct: 'text-(color:--cr-body)',
}
const THEME_VARS = [
'[--cr-page:#EEEEF3] dark:[--cr-page:#0E0E0F]',
'[--cr-panel:#F4F4FA] dark:[--cr-panel:#1B1B1C]',
'[--cr-well:#EEEEF3] dark:[--cr-well:#0E0E0F]',
'[--cr-raised:#DFDFE3] dark:[--cr-raised:#2D2D2E]',
'[--cr-raised-hover:#CACACD] dark:[--cr-raised-hover:#373738]',
'[--cr-border:#DFDFE3] dark:[--cr-border:#2D2D2E]',
'[--cr-border-strong:#CACACD] dark:[--cr-border-strong:#373738]',
'[--cr-hairline:#DFDFE3] dark:[--cr-hairline:#2D2D2E]',
'[--cr-rim:#CACACD] dark:[--cr-rim:#F4F4FA2E]',
'[--cr-text:#1B1B1C] dark:[--cr-text:#F4F4FA]',
'[--cr-body:#373738] dark:[--cr-body:#CACACD]',
'[--cr-muted:#575759] dark:[--cr-muted:#9B9B9E]',
'[--cr-faint:#7B7B7D]',
'[--cr-selection:#A0A0A233] dark:[--cr-selection:#5E5E6333]',
'[--cr-success:#047857] dark:[--cr-success:#34D399]',
'[--cr-ai:#0369A1] dark:[--cr-ai:#7DD3FC]',
'[--cr-attention:#B45309] dark:[--cr-attention:#FFB574]',
'[--cr-red:#C8262C] dark:[--cr-red:#E5484D]',
'[--cr-new:#6D28D9] dark:[--cr-new:#B79CFF]',
'[--cr-add-bg:color-mix(in_srgb,var(--cr-success)_13%,transparent)] dark:[--cr-add-bg:color-mix(in_srgb,var(--cr-success)_10%,transparent)]',
'[--cr-add-word:color-mix(in_srgb,var(--cr-success)_26%,transparent)] dark:[--cr-add-word:color-mix(in_srgb,var(--cr-success)_24%,transparent)]',
'[--cr-del-bg:color-mix(in_srgb,var(--cr-red)_8%,transparent)] dark:[--cr-del-bg:color-mix(in_srgb,var(--cr-red)_12%,transparent)]',
'[--cr-del-word:color-mix(in_srgb,var(--cr-red)_18%,transparent)] dark:[--cr-del-word:color-mix(in_srgb,var(--cr-red)_28%,transparent)]',
'[--cr-gutter:color-mix(in_srgb,var(--cr-muted)_75%,var(--cr-faint))]',
'[--cr-scroll:var(--cr-hairline)]',
'[--cr-primary:#1B1B1C] dark:[--cr-primary:#F4F4FA]',
'[--cr-primary-hover:#373738] dark:[--cr-primary-hover:#FFFFFF]',
'[--cr-on-primary:#F4F4FA] dark:[--cr-on-primary:#0E0E0F]',
'[--cr-focus:#1B1B1C99] dark:[--cr-focus:#F4F4FA99]',
'[--cr-focus-gap:#F4F4FA] dark:[--cr-focus-gap:#1B1B1C]',
'[--cr-dot:#1B1B1C] dark:[--cr-dot:#F4F4FA]',
'[--cr-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--cr-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
'[--cr-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--cr-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
'[--cr-elev-menu:0_4px_12px_rgba(0,0,0,0.10),0_16px_32px_-8px_rgba(0,0,0,0.2)] dark:[--cr-elev-menu:0_4px_12px_rgba(0,0,0,0.5),0_16px_32px_-8px_rgba(0,0,0,0.8)]',
].join(' ')
const FOCUS =
'outline-none focus-visible:ring-2 focus-visible:ring-(color:--cr-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--cr-focus-gap)'
// Inside a clipped surface an offset ring would be cut, so it draws inward.
const FOCUS_INSET =
'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-(color:--cr-focus)'
// Drawn as an overlay above the sticky gutter, which would otherwise paint over the ring.
const FOCUS_HUNK =
"outline-none focus-visible:after:pointer-events-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[2] focus-visible:after:rounded-[inherit] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-(color:--cr-focus) focus-visible:after:content-['']"
// Touch targets: the box stays as drawn, a pseudo-element grows the hit area to 44px.
const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"
// 32px buttons 8px apart: 6px up and down reaches 44, 4px a side tiles the gap.
const HIT_SM = `${HIT} pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:-inset-x-1`
// A lone 32px button: 6px on every side reaches 44 both ways.
const HIT_ICON = `${HIT} pointer-coarse:after:-inset-1.5`
// The 28px fold row grows 8px into each neighbouring gap and stops at its edges.
const HIT_FOLD = `${HIT} pointer-coarse:after:-inset-y-2 pointer-coarse:after:inset-x-0`
// Size and look are separate pieces, so no two classes fight over one property.
const BTN = `${FOCUS} inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full font-semibold transition-[background-color,color,border-color,opacity] duration-150`
const SM = 'h-8 px-4 text-[12px] leading-4'
const SM_TIGHT = 'h-8 px-3 text-[12px] leading-4'
const PRIMARY = 'bg-(--cr-primary) text-(color:--cr-on-primary) shadow-(--cr-elev-1)'
const SECONDARY = 'border border-(color:--cr-border-strong) text-(color:--cr-text) hover:bg-(--cr-raised)'
const GHOST = 'text-(color:--cr-muted) hover:bg-(--cr-raised) hover:text-(color:--cr-text)'
const TITLE = 'min-w-0 flex-1 text-[15px] leading-6 font-semibold text-(color:--cr-text) [overflow-wrap:anywhere]'
const META = 'shrink-0 text-[12px] leading-6 tabular-nums text-(color:--cr-muted)'
const WELL = 'rounded-2xl border border-(color:--cr-border) bg-(--cr-well)'
// Chrome ignores the ::-webkit pseudo-elements while the standard properties are set.
const SCROLLBAR =
'[scrollbar-color:auto] [scrollbar-width:auto] [&::-webkit-scrollbar]:size-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-(--cr-scroll) [&::-webkit-scrollbar-corner]:bg-transparent supports-[-moz-appearance:none]:[scrollbar-color:var(--cr-scroll)_transparent] supports-[-moz-appearance:none]:[scrollbar-width:thin]'
const FADE_END = 'linear-gradient(to bottom, black calc(100% - 40px), transparent)'
type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'
const TONE_VAR: Record<Tone, string> = {
neutral: 'var(--cr-dot)',
ai: 'var(--cr-ai)',
attention: 'var(--cr-attention)',
success: 'var(--cr-success)',
red: 'var(--cr-red)',
}
const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'
function subscribeReduced(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
const mq = window.matchMedia(REDUCED_QUERY)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
function getReduced() {
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}
function getReducedServer() {
return false
}
type Phase = 'review' | 'receipt'
// noScroll: the target is in view, or a mid-resize scroll would shift the card's list.
type FocusRequest = { key: string; noScroll: boolean }
type ReviewProps = {
title: string
checkSummary: string
files: DiffFile[]
decisions: Record<string, HunkDecision>
collapsed: Record<string, boolean>
folds: Record<string, boolean>
left: number
keptCount: number
allReviewed: boolean
rise: boolean
reduce: boolean
t: Tuning
onMounted: () => void
onDecide: (id: string, d: HunkDecision | null, viaKeyboard: boolean) => void
onToggleFile: (path: string) => void
onToggleFold: (key: string) => void
onKeepAll: (viaKeyboard: boolean) => void
onApply?: () => void
keyHints: string
}
function beatsFor(files: DiffFile[]) {
let n = 1
const out = files.map((file) => ({
file: n++,
hunks: file.hunks.map((h) => ({
lead: splitLeading(h.lines).lead.length > 0 ? n++ : -1,
hunk: n++,
})),
}))
return { files: out, footer: n }
}
type Enter = {
initial: false | { opacity: number; y?: number }
animate: { opacity: number; y: number }
transition: { duration: number; ease: typeof EASE; delay: number }
}
const GLIDE = 'transition-[color,background-color,text-decoration-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]'
function sliceTokens(tokens: Token[], from: number, to: number) {
const out: Token[] = []
let at = 0
for (const tok of tokens) {
const end = at + tok.text.length
if (end > from && at < to) {
out.push({ text: tok.text.slice(Math.max(from - at, 0), Math.min(to, end) - at), kind: tok.kind })
}
at = end
if (at >= to) break
}
return out
}
/**
* @typedef {object} DiffLine
* @property {DiffLineKind} kind Selects context, addition, deletion or folded-line rendering.
* @property {string} text Line text, or newline-joined hidden lines when kind is fold.
* @property {number} [oldNo] First old-file line number shown for the line or fold; left out, the old side is blank.
* @property {number} [newNo] First new-file line number shown for the line or fold; left out, the new side is blank.
* @property {[number, number][]} [marks] Zero-based text ranges emphasized within the line; left out, no text is emphasized.
* @property {number} [count] Number of hidden lines represented by a fold; left out, the fold derives its count from the hidden text.
*/
/**
* @typedef {object} DiffHunk
* @property {string} id Stable hunk identifier that must be unique across all hunks in the files list.
* @property {string} reason Explanation shown above the hunk decision controls.
* @property {DiffLine[]} lines Diff lines rendered for the hunk.
*/
/**
* @typedef {object} DiffFile
* @property {string} path File path used as the collapsible file heading.
* @property {DiffHunk[]} hunks Reviewable changes contained in the file.
*/
/**
* @typedef {object} ChangeReviewProps
* @property {string} [title] Sets the review heading; left out, the demo uses its sample title and host mode shows an empty heading, while passing it turns the demo off.
* @property {string} [checkSummary] Shows a check result below the heading; left out, the demo uses its sample result and host mode shows none, while passing it turns the demo off.
* @property {DiffFile[]} [files] Supplies the files and hunks to review; left out, the demo uses sample changes, while passing it turns the demo off.
* @property {(hunkId: string, decision: HunkDecision | null) => void} [onDecide] Reports keep, discard and cleared decisions while decisions are still tracked locally; left out, no decision updates are reported.
* @property {(keptIds: string[]) => void} [onApply] Receives the kept hunk ids and makes the Apply control available outside the demo; left out, host reviews have no Apply control.
* @property {() => void} [onUndo] Handles undoing an applied review and makes the receipt Undo control available outside the demo; left out, host receipts have no Undo control.
* @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
* @property {Partial<Tuning>} [tuning] Overrides review measurements, timing and motion values; left out, every value in TUNING is used.
*/
```State
- every hook, handler, effect and disposal
- the animation loop, frame by frame
Tree
- the JSX, every className and inline style
Why · Remix · Check
- the mechanism, the tuning points, the checks
Unlock Diff Review
The full source and the remix prompt ship with Premium.