Create Record Form
A full-page create form with a guidance rail and a Save-and-create-another that keeps the shared fields - the shape a record with more than six fields actually needs.
An edit page that shows the diff - per-field change markers carrying the previous value, a per-field revert, and a Save button that counts what it will write.
components/crud-update-form.tsxnpx hoverlab add crud-update-form
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <CrudUpdateForm> — Update, as a page, with the diff on screen.
*
* An edit form differs from a create form by exactly one thing, and it is the
* thing most edit forms leave out: there is a previous value, and the user
* needs to know which fields they have moved away from it.
*
* Without that, three failures are routine. Someone tabs through a form and
* changes a select by accident, saves, and never knows. Someone makes four
* edits, gets interrupted, comes back and cannot tell which four. Someone
* hits Save on a form they only opened to read.
*
* SO: PER-FIELD CHANGE MARKERS AND A REVIEW BEFORE SAVE
*
* - A changed field is marked in place, with its previous value legible
* beside it, and carries its own Revert. Reverting one field should not
* mean reloading the page and losing the other three.
* - The primary button is **disabled until something changes** and counts
* the changes on its face. "Save 3 changes" is a different promise from
* "Save", and the count is the cheapest possible confirmation step.
* - The summary is a real diff — old → new — rather than a list of field
* names, because "you changed Payment terms" does not tell you whether to
* press the button.
*
* THE UPDATED-BY LINE IS NOT DECORATION. A shared record is edited by other
* people, and the last-edited stamp is the only warning a user gets that they
* may be about to overwrite someone. Optimistic concurrency belongs in the
* request; showing whose work is at stake belongs here.
*
* ACCESSIBILITY: every input has a real `<label>`; the change count is a
* polite live region so it is announced as fields move; each Revert names its
* field, so a screen-reader user hears "Revert payment terms" rather than six
* identical buttons.
*/
import * as React from 'react'
import { ArrowRight, History, RotateCcw } from 'lucide-react'
export interface UpdateField {
id: string
label: string
type?: 'text' | 'email' | 'select' | 'textarea'
options?: string[]
hint?: string
/** The persisted value. Anything else is an unsaved change. */
original: string
}
export interface CrudUpdateFormProps {
heading?: string
lastEdited?: string
fields?: UpdateField[]
className?: string
}
const DEFAULT_FIELDS: UpdateField[] = [
{ id: 'name', label: 'Legal name', original: 'Northwind Trading Ltd' },
{ id: 'email', label: 'Billing email', type: 'email', original: 'ap@northwind.example' },
{
id: 'terms',
label: 'Payment terms',
type: 'select',
options: ['Net 14', 'Net 30', 'Net 60', 'On receipt'],
original: 'Net 30',
hint: 'Applies to orders raised from now on, not to open ones.',
},
{
id: 'currency',
label: 'Currency',
type: 'select',
options: ['GBP', 'EUR', 'USD', 'INR'],
original: 'EUR',
},
{
id: 'tier',
label: 'Risk tier',
type: 'select',
options: ['Tier 1', 'Tier 2', 'Tier 3'],
original: 'Tier 2',
},
{
id: 'notes',
label: 'Internal notes',
type: 'textarea',
original: 'Ships from Rotterdam. Lead time doubles in December.',
},
]
const INPUT_CLASS =
'w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring'
export function CrudUpdateForm({
heading = 'Edit supplier',
lastEdited = 'Sam Okafor, 14 August 2026',
fields = DEFAULT_FIELDS,
className = '',
}: CrudUpdateFormProps) {
const uid = React.useId()
const initial = React.useMemo(
() => Object.fromEntries(fields.map((f) => [f.id, f.original])),
[fields],
)
const [values, setValues] = React.useState<Record<string, string>>(initial)
const [saved, setSaved] = React.useState(false)
const changed = fields.filter((f) => values[f.id] !== f.original)
function set(id: string, value: string) {
setSaved(false)
setValues((v) => ({ ...v, [id]: value }))
}
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<form
className="mx-auto max-w-4xl"
onSubmit={(event) => {
event.preventDefault()
setSaved(true)
}}
>
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">{heading}</h1>
{/* The only warning a user gets that they may overwrite someone. */}
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
<History aria-hidden className="h-4 w-4" />
Last edited by {lastEdited}
</p>
</div>
<p aria-live="polite" className="text-sm text-muted-foreground">
{changed.length === 0
? saved
? 'Saved. Nothing left to write.'
: 'No unsaved changes.'
: `${changed.length} unsaved change${changed.length === 1 ? '' : 's'}.`}
</p>
</header>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
{fields.map((field) => {
const id = `${uid}-${field.id}`
const dirty = values[field.id] !== field.original
const wide = field.type === 'textarea'
const border = dirty ? 'border-primary' : 'border-border'
return (
<div key={field.id} className={wide ? 'sm:col-span-2' : undefined}>
<div className="flex items-baseline justify-between gap-2">
<label htmlFor={id} className="text-sm font-medium">
{field.label}
</label>
{dirty ? (
<button
type="button"
onClick={() => set(field.id, field.original)}
className="inline-flex items-center gap-1 rounded text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw aria-hidden className="h-3 w-3" />
Revert
<span className="sr-only"> {field.label}</span>
</button>
) : null}
</div>
{field.type === 'textarea' ? (
<textarea
id={id}
rows={3}
value={values[field.id] ?? ''}
onChange={(e) => set(field.id, e.target.value)}
aria-describedby={dirty ? `${id}-was` : field.hint ? `${id}-hint` : undefined}
className={`mt-1.5 ${INPUT_CLASS} ${border}`}
/>
) : field.type === 'select' ? (
<select
id={id}
value={values[field.id] ?? ''}
onChange={(e) => set(field.id, e.target.value)}
aria-describedby={dirty ? `${id}-was` : field.hint ? `${id}-hint` : undefined}
className={`mt-1.5 ${INPUT_CLASS} ${border}`}
>
{field.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : (
<input
id={id}
type={field.type ?? 'text'}
value={values[field.id] ?? ''}
onChange={(e) => set(field.id, e.target.value)}
aria-describedby={dirty ? `${id}-was` : field.hint ? `${id}-hint` : undefined}
className={`mt-1.5 ${INPUT_CLASS} ${border}`}
/>
)}
{dirty ? (
<p id={`${id}-was`} className="mt-1.5 truncate text-xs text-primary">
Was: {field.original}
</p>
) : field.hint ? (
<p id={`${id}-hint`} className="mt-1.5 text-xs text-muted-foreground">
{field.hint}
</p>
) : null}
</div>
)
})}
</div>
{/* A real diff, not a list of field names. */}
{changed.length > 0 ? (
<div className="mt-8 rounded-xl border border-primary/40 bg-primary/5 p-4">
<h2 className="text-sm font-semibold">About to save</h2>
<ul className="mt-3 space-y-2">
{changed.map((field) => (
<li key={field.id} className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{field.label}</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs line-through">
{field.original || '—'}
</span>
<ArrowRight aria-hidden className="h-3.5 w-3.5 text-muted-foreground rtl:rotate-180" />
<span className="rounded bg-primary/15 px-1.5 py-0.5 text-xs font-medium text-primary">
{values[field.id] || '—'}
</span>
</li>
))}
</ul>
</div>
) : null}
<div className="sticky bottom-0 z-10 mt-8 flex flex-wrap items-center justify-end gap-2 border-t border-border bg-background/90 py-4 backdrop-blur">
<button
type="button"
disabled={changed.length === 0}
onClick={() => setValues(initial)}
className="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Discard all changes
</button>
<button
type="submit"
disabled={changed.length === 0}
className="rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{changed.length === 0
? 'Save'
: `Save ${changed.length} change${changed.length === 1 ? '' : 's'}`}
</button>
</div>
</form>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/crud-update-form.tsx and import it where you need the section:
import { CrudUpdateForm } from '@/components/crud-update-form'2 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
heading | string | 'Edit supplier' |
lastEdited | string | 'Sam Okafor, 14 August 2026' |
fields | UpdateField[] | DEFAULT_FIELDS |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Edit Record Form — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="bg-background px-4 py-10 sm:px-6 ">
<form class="mx-auto max-w-4xl">
<header class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight">Edit supplier</h1>
<p class="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-history h-4 w-4" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>Last edited by Sam Okafor, 14 August 2026</p>
</div>
<p aria-live="polite" class="text-sm text-muted-foreground">No unsaved changes.</p>
</header>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
<div>
<div class="flex items-baseline justify-between gap-2">
<label for="_R_0_-name" class="text-sm font-medium">Legal name</label>
</div>
<input id="_R_0_-name" type="text" class="mt-1.5 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring border-border" value="Northwind Trading Ltd" />
</div>
<div>
<div class="flex items-baseline justify-between gap-2">
<label for="_R_0_-email" class="text-sm font-medium">Billing email</label>
</div>
<input id="_R_0_-email" type="email" class="mt-1.5 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring border-border" value="ap@northwind.example" />
</div>
<div>
<div class="flex items-baseline justify-between gap-2">
<label for="_R_0_-terms" class="text-sm font-medium">Payment terms</label>
</div>
<select id="_R_0_-terms" aria-describedby="_R_0_-terms-hint" class="mt-1.5 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring border-border">
<option value="Net 14">Net 14</option>
<option value="Net 30" selected="">Net 30</option>
<option value="Net 60">Net 60</option>
<option value="On receipt">On receipt</option>
</select>
<p id="_R_0_-terms-hint" class="mt-1.5 text-xs text-muted-foreground">Applies to orders raised from now on, not to open ones.</p>
</div>
<div>
<div class="flex items-baseline justify-between gap-2">
<label for="_R_0_-currency" class="text-sm font-medium">Currency</label>
</div>
<select id="_R_0_-currency" class="mt-1.5 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring border-border">
<option value="GBP">GBP</option>
<option value="EUR" selected="">EUR</option>
<option value="USD">USD</option>
<option value="INR">INR</option>
</select>
</div>
<div>
<div class="flex items-baseline justify-between gap-2">
<label for="_R_0_-tier" class="text-sm font-medium">Risk tier</label>
</div>
<select id="_R_0_-tier" class="mt-1.5 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring border-border">
<option value="Tier 1">Tier 1</option>
<option value="Tier 2" selected="">Tier 2</option>
<option value="Tier 3">Tier 3</option>
</select>
</div>
<div class="sm:col-span-2">
<div class="flex items-baseline justify-between gap-2">
<label for="_R_0_-notes" class="text-sm font-medium">Internal notes</label>
</div>
<textarea id="_R_0_-notes" rows="3" class="mt-1.5 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring border-border">Ships from Rotterdam. Lead time doubles in December.</textarea>
</div>
</div>
<div class="sticky bottom-0 z-10 mt-8 flex flex-wrap items-center justify-end gap-2 border-t border-border bg-background/90 py-4 backdrop-blur">
<button type="button" disabled="" class="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Discard all changes</button>
<button type="submit" disabled="" class="rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Save</button>
</div>
</form>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse CRUDCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A full-page create form with a guidance rail and a Save-and-create-another that keeps the shared fields - the shape a record with more than six fields actually needs.
Three fields over the list they add to, with the new row appearing behind the dialog rather than after it closes.
A long create form in a side drawer: the fields scroll, the header and the action bar do not, and the list stays legible beside it.
The read view arranged by what a reader came for - a verdict band of four facts, then details, then the related records a field dump can never show.
The verification modal from a table row: copyable identifiers, no editing, nothing scrolling, and the row still marked behind it.
The edit modal including the parts nobody draws: the in-flight state, the rejected save that keeps your typing, and the conflict when someone else got there first.
The save-on-blur model, with per-field confirmation, per-field failure and a written-in-this-session log - the only honest answer to what did I just change.
Deleting a record other records depend on: dependents counted and grouped by fate - deleted, orphaned, retained - with a guard that scales to the blast radius.
The screen after the write: the reference copyable, next actions ranked rather than listed, and an undo window that counts down honestly and then says why it is gone.
One change applied to many records that disagree - mixed values shown as mixed, untouched fields never written, and skipped rows counted before you commit.
The bin with the clock on it: days until purge per row, who archived it, and a restore disabled with a reason when the parent has already gone.