Locks a finished or approved record and states that edits need re-approval before unlocking, so approved invoices aren't changed by mistake.
StopsApproved invoice edited by mistake
"use client"
import * as React from "react"
import { EditLock } from "@/components/ui/edit-lock"
const field =
"h-9 w-full min-w-0 rounded-md border bg-transparent px-3 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed md:text-sm dark:bg-input/30"
export function Demo() {
const [status, setStatus] = React.useState("Approved by Priya")
return (
<div className="w-full max-w-md space-y-2">
<p className="text-xs text-muted-foreground">
PO-2291 · <span aria-live="polite">{status}</span>
</p>
<EditLock lockedBy="Priya" relockAfter={30_000} onEdit={() => setStatus("Needs re-approval")}>
<div className="grid grid-cols-2 gap-3 text-sm">
<label className="col-span-2 space-y-1.5">
<span className="font-medium">Vendor</span>
<input className={field} defaultValue="Shree Packaging Pvt Ltd" />
</label>
<label className="space-y-1.5">
<span className="font-medium">Amount (₹)</span>
<input className={field} inputMode="decimal" defaultValue="1,84,500" />
</label>
<label className="space-y-1.5">
<span className="font-medium">Due</span>
<input className={field} defaultValue="30 Oct 2026" />
</label>
</div>
</EditLock>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uiedit-lock.tsx238 lines"use client" // Bollard UI — edit-lock · MIT licence · https://bollard-ui.vercel.app/components/edit-lock import * as React from "react" import { Lock, LockOpen, TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" const action = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50" const tones = { locked: "border-border bg-muted/50", open: "border-amber-500/30 bg-amber-500/10 text-amber-950 dark:text-amber-50", void: "border-red-500/30 bg-red-500/10 text-red-950 dark:text-red-50", } as const const outline = "border bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50" const solid = "bg-amber-700 text-white hover:bg-amber-800 dark:bg-amber-400 dark:text-amber-950 dark:hover:bg-amber-300" const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)) /** "0:42" */ export const formatClock = (ms: number) => { const s = Math.max(0, Math.ceil(ms / 1000)) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}` } export interface EditLockProps extends Omit<React.ComponentProps<"div">, "defaultValue"> { /** Who locked it, e.g. "Priya". */ lockedBy: string /** Why it is locked, e.g. "approval", "sign-off". */ reason?: string /** Identifies the approval. A new value (a fresh approval) clears the "needs re-approval" state. */ approvalId?: string /** Controlled lock state. */ locked?: boolean /** Initial lock state when uncontrolled. */ defaultLocked?: boolean /** Called when the record locks or unlocks. */ onLockedChange?: (locked: boolean) => void /** Called once, on the first change made after unlocking: the moment the approval stops being valid. */ onEdit?: () => void /** Relocks after this many ms without any typing, clicking or key presses inside. */ relockAfter?: number /** Templates: `{name}`, `{reason}` and `{time}` are filled in. */ lockedText?: string /** Text for the unlock button. */ unlockLabel?: string /** Explains what unlocking means (e.g. re-approval). */ consequenceText?: string /** Text for confirming the unlock. */ confirmUnlockLabel?: string /** Text for cancelling the unlock. */ cancelLabel?: string /** Shown while the record is unlocked. */ unlockedText?: string /** Shown once a change has voided the approval. */ editedText?: string /** Shown when the record changed elsewhere. */ staleText?: string /** Text for the lock-now button. */ lockNowLabel?: string /** Read to screen readers instead of the ticking visible text, which would be announced every second. */ unlockedAnnouncement?: string /** Announced to screen readers when an edit voids the approval. */ editedAnnouncement?: string /** Announced once when the idle relock is this close: `{time}` is filled in. */ relockSoonText?: string /** How close the relock must be, in ms, before `relockSoonText` is announced. */ relockWarnWithin?: number } /** * Stops accidental edits to finished, approved records. The form sits in a * native disabled fieldset with a plain "Locked by Priya after approval" * line. Unlocking first says what it costs (re-approval), but the approval is * only voided if you actually change something, so opening it to look is * free. It relocks itself after a stretch of idleness, never mid-typing. */ export function EditLock({ lockedBy, reason = "approval", approvalId, locked, defaultLocked = true, onLockedChange, onEdit, relockAfter = 60_000, lockedText = "Locked by {name} after {reason}", unlockLabel = "Unlock to edit", consequenceText = "Any change voids {name}'s {reason}. It will need re-approval.", confirmUnlockLabel = "Unlock", cancelLabel = "Cancel", unlockedText = "Unlocked · nothing changed yet · relocks in {time}", editedText = "Edited · needs re-approval · relocks in {time}", staleText = "Locked · changed since {name}'s {reason}, needs re-approval", lockNowLabel = "Lock now", unlockedAnnouncement = "Unlocked. Nothing changed yet.", editedAnnouncement = "Edited. Needs re-approval.", relockSoonText = "Relocks in {time} unless you keep working.", relockWarnWithin = 10_000, className, children, onInput, ...props }: EditLockProps) { const [inner, setInner] = React.useState(defaultLocked) const isLocked = locked ?? inner const [asking, setAsking] = React.useState(false) const [edited, setEdited] = React.useState(false) const [deadline, setDeadline] = React.useState(0) const [now, setNow] = React.useState(0) const unlockRef = React.useRef<HTMLButtonElement>(null) const fieldsetRef = React.useRef<HTMLFieldSetElement>(null) React.useEffect(() => setEdited(false), [approvalId]) const setLocked = React.useCallback( (next: boolean) => { if (locked === undefined) setInner(next) onLockedChange?.(next) }, [locked, onLockedChange], ) const bump = React.useCallback(() => setDeadline(Date.now() + relockAfter), [relockAfter]) // While unlocked, tick once a second and relock when the idle deadline passes. React.useEffect(() => { if (isLocked) return bump() setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) }, [isLocked, bump]) React.useEffect(() => { if (isLocked || !deadline || now < deadline) return const focusInside = fieldsetRef.current?.contains(document.activeElement) setLocked(true) if (focusInside) unlockRef.current?.focus() }, [isLocked, deadline, now, setLocked]) const unlock = () => { setAsking(false) setLocked(false) // Wait a frame for the fieldset to enable, then put the caret in the first field. requestAnimationFrame(() => fieldsetRef.current?.querySelector<HTMLElement>("input, textarea, select, [contenteditable]")?.focus(), ) } const values = { name: lockedBy, reason, time: formatClock(deadline - now) } const tone = !isLocked ? (edited ? tones.void : tones.open) : edited ? tones.void : tones.locked const remaining = isLocked || !deadline ? 0 : Math.max(0, (deadline - now) / relockAfter) let text = fill(lockedText, values) if (asking) text = fill(consequenceText, values) else if (!isLocked) text = fill(edited ? editedText : unlockedText, values) else if (edited) text = fill(staleText, values) // The visible line ticks every second; screen readers get the state, plus one heads-up before relocking. const soon = !isLocked && deadline > 0 && deadline - now <= relockWarnWithin // A fixed value, so the heads-up is read once rather than every second. const soonText = fill(relockSoonText, { time: formatClock(relockWarnWithin) }) const announcement = asking || isLocked ? text : soon ? soonText : edited ? editedAnnouncement : unlockedAnnouncement return ( <div data-slot="edit-lock" data-state={isLocked ? "locked" : "unlocked"} data-edited={edited || undefined} className={cn("w-full space-y-3", className)} onPointerDownCapture={() => !isLocked && bump()} onKeyDownCapture={() => !isLocked && bump()} onInput={(event) => { if (!isLocked && !edited) { setEdited(true) onEdit?.() } onInput?.(event) }} {...props} > <style>{`@keyframes cui-edit-lock-snap{0%{transform:translateY(-3px) scale(1.15)}60%{transform:translateY(1px)}100%{transform:none}}`}</style> <div className={cn("relative overflow-hidden rounded-lg border transition-colors", tone)}> <div className="flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1.5 px-3 py-1.5 text-sm"> <span key={String(isLocked)} aria-hidden className="motion-safe:animate-[cui-edit-lock-snap_300ms_ease-out]"> {asking || (edited && isLocked) ? ( <TriangleAlert className="size-4 text-red-600 dark:text-red-400" /> ) : isLocked ? ( <Lock className="size-4 text-muted-foreground" /> ) : ( <LockOpen className={cn("size-4", edited ? "text-red-600 dark:text-red-400" : "text-amber-600 dark:text-amber-400")} /> )} </span> <p className="min-w-0 flex-1 basis-48 font-medium tabular-nums">{text}</p> <span className="sr-only" aria-live="polite"> {announcement} </span> <div className="ml-auto flex gap-2" onKeyDown={(event) => event.key === "Escape" && asking && (setAsking(false), unlockRef.current?.focus())} > {asking ? ( <> <button type="button" onClick={() => (setAsking(false), unlockRef.current?.focus())} className={cn(action, outline)}> {cancelLabel} </button> <button type="button" autoFocus onClick={unlock} className={cn(action, solid)}> {confirmUnlockLabel} </button> </> ) : isLocked ? ( <button ref={unlockRef} type="button" onClick={() => setAsking(true)} className={cn(action, outline)}> <LockOpen aria-hidden className="size-4" /> {unlockLabel} </button> ) : ( <button ref={unlockRef} type="button" onClick={() => setLocked(true)} className={cn(action, outline)}> <Lock aria-hidden className="size-4" /> {lockNowLabel} </button> )} </div> </div> <span aria-hidden className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-current opacity-40 transition-transform duration-1000 ease-linear motion-reduce:transition-none" style={{ transform: `scaleX(${remaining})` }} /> </div> <fieldset ref={fieldsetRef} disabled={isLocked} aria-label={isLocked ? text : undefined} className="min-w-0 transition-opacity disabled:opacity-60"> {children} </fieldset> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { EditLock } from "@/components/ui/edit-lock"
<EditLock lockedBy="Priya" relockAfter={30_000} onEdit={() => setStatus("Needs re-approval")}>
<div className="grid grid-cols-2 gap-3 text-sm">
<label className="col-span-2 space-y-1.5">
<span className="font-medium">Vendor</span>
<input className={field} defaultValue="Shree Packaging Pvt Ltd" />
</label>
<label className="space-y-1.5">
<span className="font-medium">Amount (₹)</span>
<input className={field} inputMode="decimal" defaultValue="1,84,500" />
</label>
<label className="space-y-1.5">
<span className="font-medium">Due</span>
<input className={field} defaultValue="30 Oct 2026" />
</label>
</div>
</EditLock>What it catches
- Accidental edits to approved records
- Unlocking without knowing the consequence, via a confirmation step
- Approvals voided by an unlock with no real change (only real edits void them)
- Records left unlocked, by relocking after idleness
Props
| Prop | Type | Default |
|---|---|---|
lockedBy* (required)Who locked it, e.g. "Priya". | string | — |
reasonWhy it is locked, e.g. "approval", "sign-off". | string | "approval" |
approvalIdIdentifies the approval. A new value (a fresh approval) clears the "needs re-approval" state. | string | — |
lockedControlled lock state. | boolean | — |
defaultLockedInitial lock state when uncontrolled. | boolean | true |
onLockedChangeCalled when the record locks or unlocks. | (locked: boolean) => void | — |
onEditCalled once, on the first change made after unlocking: the moment the approval stops being valid. | () => void | — |
relockAfterRelocks after this many ms without any typing, clicking or key presses inside. | number | 60_000 |
lockedTextTemplates: `{name}`, `{reason}` and `{time}` are filled in. | string | "Locked by {name} after {reason}" |
unlockLabelText for the unlock button. | string | "Unlock to edit" |
consequenceTextExplains what unlocking means (e.g. re-approval). | string | "Any change voids {name}'s {reason}. It will need re-appr… |
confirmUnlockLabelText for confirming the unlock. | string | "Unlock" |
cancelLabelText for cancelling the unlock. | string | "Cancel" |
unlockedTextShown while the record is unlocked. | string | "Unlocked · nothing changed yet · relocks in {time}" |
editedTextShown once a change has voided the approval. | string | "Edited · needs re-approval · relocks in {time}" |
staleTextShown when the record changed elsewhere. | string | "Locked · changed since {name}'s {reason}, needs re-appro… |
lockNowLabelText for the lock-now button. | string | "Lock now" |
unlockedAnnouncementRead to screen readers instead of the ticking visible text, which would be announced every second. | string | "Unlocked. Nothing changed yet." |
editedAnnouncementAnnounced to screen readers when an edit voids the approval. | string | "Edited. Needs re-approval." |
relockSoonTextAnnounced once when the idle relock is this close: `{time}` is filled in. | string | "Relocks in {time} unless you keep working." |
relockWarnWithinHow close the relock must be, in ms, before `relockSoonText` is announced. | number | 10_000 |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Locks with a native disabled <fieldset>
- Lock state and edits are announced in an aria-live="polite" region
- The unlock confirmation focuses its confirm button; Escape cancels and returns focus
- After unlocking, focus moves to the first field
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: Enforce the lock on the server: reject edits to approved records unless the approval is voided and logged.
- A UI lock: the record can still be changed through your API
- Re-approval is signalled through onEdit; your workflow must act on it
What it stops
Approved invoice edited by mistake
How people describe it
Also known as
read only · locked form · approval · invoice · purchase order · fieldset disabled · edit mode · po
Field type