Terms Changes

Shows what changed in updated terms since the version a user accepted, and keeps Accept locked until each change has been on screen.

StopsTerms accepted without seeing changes

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/terms-changes.json

The files land in your project. They're yours to edit.

Usage

terms-changes-usage.tsx
import { TermsChanges, type TermsAcceptance } from "@/components/ui/terms-changes"

<TermsChanges
  title="Terms of Service"
  accepted={{ version: "v2.1", text: v2, date: "4 Mar 2025" }}
  current={{ version: "v3.0", text: v3, date: "1 Nov 2026" }}
  summary={[
    "Full refunds now within 14 days instead of 30.",
    "Your uploads may improve recommendations unless you opt out.",
    "Disputes go to arbitration in Mumbai, not the Pune courts.",
  ]}
  onAccept={async (r) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    setRecord(r)
  }}
/>

What it catches

  • Every change since the accepted version, as a word diff
  • Changed numbers (fees, days, limits), called out
  • Changes not yet on screen for the dwell time
  • An early Accept tap, which jumps to the next unread change
  • Consent record with both versions, timestamp and changes seen

Props

TermsChanges props
PropTypeDefault
title* (required)

Document name, e.g. "Terms of Service".

string—
accepted* (required)

The version this person accepted last.

TermsVersion—
current* (required)

The version they're asked to accept.

TermsVersion—
summary* (required)

Plain-language bullets. They must match the wording changes shown below them.

string[]—
onAccept* (required)

Records consent. Return a promise to show a pending state; a rejection lets them try again.

(record: TermsAcceptance) => void | Promise<void>—
onDecline

Optional "Not now" action.

() => void—
dwell

How long (ms) a change must stay on screen to count as viewed, so scrolling past doesn't count.

number800
foldAfter

Unchanged runs longer than this many words are folded.

number80
headingText

Templates: `{title}`, `{from}`, `{fromDate}`, `{to}`, `{toDate}`, `{seen}`, `{total}`, `{count}` are filled in.

string"Our {title} changed"
introText

Intro line above the changes.

string"You accepted {from} ({fromDate}). Here's exactly what ch…
summaryLabel

Heading of the plain-language summary.

string"In short"
changesLabel

Accessible label of the changes region.

string"Exact wording changes"
progressText

Progress text. `{seen}` and `{total}` are filled in.

string"{seen} of {total} read"
nextLabel

Label of the jump-to-next-unread button.

string"Next change"
acceptLabel

Label of the Accept button.

string"Accept {to}"
remainingText

Says how many changes are still unread.

string"Read the highlighted changes first ({count} left)."
acceptedText

Shown after accepting.

string"You accepted {to}."
declineLabel

Label of the Decline button.

string"Not now"
foldText

Label for folded unchanged text.

string"{count} unchanged words"

* required. Also accepts the native props of <section> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Changes region is focusable (tabIndex=0) with an aria-label
  • Removed/added text has sr-only prefixes; read changes marked with an aria-labelled check
  • Progress in a polite live region; Accept uses aria-disabled and is described by what's left
  • Scrolling jumps respect reduced motion

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Record consent server-side with the version ids and timestamp, and check the accepted version on later requests.

  • "Seen" means on screen for dwell ms, not read or understood
  • Not legal advice on what counts as valid consent in your jurisdiction
  • Store the consent record server-side yourself

What it stops

Terms accepted without seeing changes

Also known as

terms of service · privacy policy · tos update · accept terms · diff · consent · legal · dpdp

Field type

Dependencies

Pairs well with