Components
useDiffReview
DiffReview without its markup: the review’s state, keyboard and result, for your own design system.
Example
- @@ -1,6 +1,7 @@not decided
+ import { ratelimit } from '@/lib/ratelimit'; - @@ -11,6 +12,15 @@not decided
+ const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'; + const { success, reset } = await ratelimit.limit(ip); + if (!success) { + return new Response('Too many requests', { + status: 429, + headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) }, + }); + } + - @@ -18,7 +28,7 @@not decided
- model: openai('gpt-4o'), + model: openai('gpt-4.1-mini'),
J and K move, A and R decide, C writes a note.
Interactive, and the same on the components page. Try the keyboard below on it.
Installation
npm i signoff-ui aiimport { useDiffReview, reviewToolOutput } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';npx shadcn@latest add @signoff-ui/use-diff-reviewimport { useDiffReview } from '@/components/signoff-ui/use-diff-review';The styles are once per app; Getting started has the Tailwind v4 and plain CSS options.
Usage
function MyReview({ files, onSubmit }: { files: FileChange[]; onSubmit: (r: DiffReviewResult) => void }) {
const review = useDiffReview({ files, onSubmit });
return (
<section aria-label="Review" {...review.getRootProps()}>
{review.items.map((item, i) => (
<div key={item.id} {...review.getItemProps(i)}>
<MyHunk hunk={item.hunk} />
<button {...review.getDecisionProps(i, 'accepted')}>Keep</button>
<button {...review.getDecisionProps(i, 'rejected')}>Drop</button>
</div>
))}
{review.draft && <textarea aria-label="Comment" {...review.getDraftProps()} />}
<button {...review.getSubmitProps()}>Apply</button>
<p role="status">{review.announcement}</p>
</section>
);
}API reference
Generated from the types the package ships, so it matches the version you install.
useDiffReview
The review without its markup: parsing (in a worker for large files), decisions, comments,
viewed files, unchanged context, the keyboard and the result, for teams that render their own.
DiffReview is this hook and its styled markup. Spread getRootProps() on the element that
contains the review, getItemProps(i) on each item's element, and render announcement in a
polite live region; the keys are DiffReview's.
useDiffReview({ files: changes, decisions: decisionsProp, defaultDecisions, onDecisionsChange, comments: commentsProp, defaultComments, onCommentsChange, viewed: viewedProp, defaultViewed, onViewedChange, onSubmit, readOnly, context, maxEditLength, diffWorker, autoAdvance, collapseViewed, labels }: UseDiffReviewOptions): UseDiffReviewResultParameter: UseDiffReviewOptions
filesrequiredreadonly FileChange[]decisionsReadonly<Record<string, HunkDecision>>Controlled decisions keyed by item id: a hunk's
${path}:${index}, or${path}:filefor a file with no hunks (binary, renamed or empty). A repeated path gets${path}#2,#3and so on, skipping a suffix another file's path already has.defaultDecisionsReadonly<Record<string, HunkDecision>>onDecisionsChange(decisions: Record<string, HunkDecision>) => voidcommentsreadonly DiffReviewComment[]Controlled comments.
defaultCommentsreadonly DiffReviewComment[]onCommentsChange(comments: DiffReviewComment[]) => voidviewedReadonly<Record<string, boolean>>Controlled "Viewed" marks, by file id (the path, or
path#2… for a repeated path).defaultViewedReadonly<Record<string, boolean>>onViewedChange(viewed: Record<string, boolean>) => voidonSubmit(result: DiffReviewResult, review: { toPatch: () => string; }) => void | PromiseLike<void>Called with the reviewed result, and a
toPatchthat gives the accepted changes as a unified diff. Pending hunks are not applied. Fires once per review state, so a double click or a repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or viewed mark, changedfiles, the returned promise to settle, or the handler to throw. Files compare by content and the rest by value: an equal new array or object does not count as a change.readOnlybooleanDecide nothing, comment on nothing: only read.
contextnumberLines of context around each change. Default 3.
maxEditLengthnumberPast this many lines added plus lines removed in one file, its changed region (from the first changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing costs about the square of this number. Default 2,000.
diffWorkerDiffWorkerFactory | falseWhere files too large to diff while rendering are diffed: by default in the package's own worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a function that starts your own
Workerrunningsignoff-ui's diff worker, orfalseto diff them on the main thread after the first paint. Without a working worker the latter is the fallback.autoAdvancebooleanAfter accepting or rejecting with the keyboard, move to the next undecided item. Default
true.collapseViewedbooleanFold a file away once it is marked viewed. Default
true.labelsSignoffLabelsInputWords to use instead of the English defaults, for what the hook announces and names: see
SignoffLabelsProvider.
Returns: UseDiffReviewResult
filesrequiredDiffReviewFileState[]itemsrequiredReviewItem[]Every item to decide, in file order;
indexin the getters refers to this list.activeIndexrequirednumberThe item in the tab order, focused by J and K.
countsrequired{ accepted: number; rejected: number; pending: number; total: number; }comparingrequiredbooleanSome file is still being diffed in the background; applying waits for it.
decisionsrequiredReadonly<Record<string, HunkDecision>>commentsrequiredreadonly DiffReviewComment[]viewedrequiredReadonly<Record<string, boolean>>selectionrequiredDiffReviewSelectiondraftrequiredDiffReviewDraftshownrequiredReadonly<Record<string, ShownContext>>Unchanged lines shown around hunks, by
${fileId}:${gap.before}.announcementrequiredstringWhat to announce: render it in a polite live region.
readOnlyrequiredbooleanisMacrequiredbooleandeciderequired(id: string, decision: HunkDecision, options?: { advance?: boolean; }) => voiddecideFilerequired(fileId: string, decision: Exclude<HunkDecision, 'pending'>) => voiddecideAllrequired(decision: Exclude<HunkDecision, 'pending'>) => voidsetViewedrequired(fileId: string, viewed: boolean) => voidsetCollapsedrequired(fileId: string, collapsed: boolean) => voidfocusItemrequired(index: number) => voidFocus an item by index (clamped), as J and K do.
activaterequired(index: number) => voidMake an item the one in the tab order, as focusing it does. Stable across renders.
registerItemrequired(id: string, el: HTMLElement | null) => voidRecord an item's element, for focus. Stable across renders.
focusFilerequired(fileId: string) => voidUnfold a file and focus its first item, as the file navigator does.
selectLinesrequired(itemId: string, from: number, to?: number) => voidSelect lines
from…toof a hunk item;todefaults tofrom.clearSelectionrequired() => voidstartCommentrequired(options?: { itemId?: string; commentId?: string; }) => voidStart a comment: on the selected lines, else on the item (a hunk or a whole file), or edit one.
setDraftTextrequired(text: string) => voidregisterDraftrequired(el: HTMLTextAreaElement | null) => voidThe comment editor's element, for focus. Stable across renders.
saveDraftrequired() => voidcancelDraftrequired() => voidremoveCommentrequired(id: string) => voidshowContextrequired(fileId: string, gap: ContextGap, from: 'start' | 'end' | 'all') => voidShow more unchanged lines of a file's gap: from its start, its end, or all of it.
submitrequired() => voidSend the review to
onSubmit, once per review state.resultrequired() => DiffReviewResultThe review as
onSubmitreceives it, for the current state.toPatchrequired() => stringThe accepted changes as a unified diff.
getRootPropsrequired() => { onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; }getItemPropsrequired(index: number) => { ref: (el: HTMLElement | null) => void; role: 'group'; tabIndex: 0 | -1; 'aria-label': string; 'data-decision': HunkDecision; onFocus: () => void; }A focusable item: a roving tabindex, its accessible name and its decision.
getDecisionPropsrequired(index: number, decision: Exclude<HunkDecision, 'pending'>) => { type: 'button'; 'aria-pressed': boolean; onClick: () => void; }Accept or reject one item; pressing it again resets it.
getFileDecisionPropsrequired(fileId: string, decision: Exclude<HunkDecision, 'pending'>) => { type: 'button'; onClick: () => void; 'aria-keyshortcuts': string; }getViewedPropsrequired(fileId: string) => { ref: (el: HTMLInputElement | null) => void; type: 'checkbox'; checked: boolean; onChange: (event: ChangeEvent<HTMLInputElement>) => void; 'aria-keyshortcuts': 'V'; }getLineNumberPropsrequired(index: number, line: number) => { onClick: (event: MouseEvent) => void; }Click a line number to select it; Shift-click extends the selection in the same hunk.
getDraftPropsrequired() => { ref: (el: HTMLTextAreaElement | null) => void; value: string; onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void; onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void; }getSubmitPropsrequired() => { type: 'button'; onClick: () => void; 'aria-disabled': true | undefined; 'aria-keyshortcuts': string; }
DiffReviewFileState
idrequiredstringThe file's id: its path, or
path#2… for a path that repeats.pathrequiredstringchangerequiredFileChangeparsedrequiredParsedFileDiffundefinedwhile the file is diffed in the background ("Comparing…").itemsrequiredReviewItem[]What it asks to decide: its hunks, or the file as a whole.
decisionrequiredFileDecision'pending' | 'accepted' | 'rejected' | 'partial' | 'unchanged'decidedrequirednumberHow many of its items are decided.
viewedrequiredbooleancollapsedrequiredbooleanFolded away: its items are skipped by J and K.
gapsrequiredContextGap[]Unchanged lines around its hunks that can be shown, when both contents are known.
DiffReviewSelection
Lines selected in a hunk, by index into hunk.lines.
itemrequiredReviewItemhunkrequiredDiffHunkanchorrequirednumberWhere the selection started, and where it ends (the line moved with the keyboard).
headrequirednumberfromrequirednumberThe first and last selected index.
torequirednumberrangerequiredLineRange
DiffReviewDraft
A comment being written: a new one, or an edit of commentId.
fileIdrequiredstringtargetrequiredDiffReviewComment['target']textrequiredstringcommentIdstringhunkIdstringrangeLineRange
ReviewItem
One thing to decide: a hunk, or a file with no hunks as a whole (binary, a rename, an empty file).
idrequiredstringThe decision key: the hunk's id, or
${fileId}:file.filerequiredParsedFileDiffhunkrequiredDiffHunkundefinedfor a whole file.
Accessibility
- The hook has no markup of its own. Its getters bring what
DiffReviewrelies on: a roving tabindex and a name for each item (getItemProps),aria-pressedon decision buttons,aria-keyshortcuts,aria-disabledon Apply while files are being compared, and a roving tabindex for a file list (getNavigatorItemProps). - Every key
DiffReviewhas works oncegetRootProps()is on the element around the review andgetItemProps(i)on each item. Renderannouncementin a polite live region: it says what each decision, selection, comment and viewed mark did. - Give the comment editor a label:
getDraftProps()gives it focus, its value and its keys (⌘ or Ctrl + Enter saves, Escape cancels).