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.
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.
components/crud-update-modal.tsxnpx hoverlab add crud-update-modal
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'
/**
* <CrudUpdateModal> — Update, in a modal, and the save that fails.
*
* Every edit modal in every design system shows the happy path: fields, a
* Cancel, a Save. The interesting half is what happens after Save, and it is
* the half nobody draws.
*
* THE THREE STATES THIS BLOCK IS ACTUALLY ABOUT
*
* - **In flight.** Both buttons disabled, not just the primary. A Cancel
* that stays live during a request lets the user close a modal whose write
* is still going, and then wonder whether it landed.
* - **Rejected by the server.** The modal stays open, keeps every value the
* user typed, and shows the reason above the fields as an alert. The
* common bug is closing on submit and surfacing the failure as a toast,
* which discards the typing.
* - **Conflicted.** Someone else saved while this modal was open. That is
* not a validation error and must not read like one: the offer is to
* reload their version or overwrite it, stated in those words.
*
* WHY THE CONFLICT PATH IS IN THE BLOCK AND NOT LEFT AS AN EXERCISE
*
* Because it is the failure that loses work, and because the markup for it is
* the same shape as the error state — one banner, two buttons — so its cost
* is a dozen lines. The demo alternates: the first save fails on validation,
* the second reports a conflict, the third succeeds. That is a demo, not a
* suggestion about your API.
*
* ACCESSIBILITY: `role="dialog"` with `aria-modal`; the failure banner is
* `role="alert"` so it is announced without moving focus away from the field
* the user was in; the submit button's label changes with its state rather
* than only its spinner, so the state is not carried by an animation alone.
*/
import * as React from 'react'
import { Loader2, RefreshCw, TriangleAlert, X } from 'lucide-react'
export type SaveOutcome = 'idle' | 'saving' | 'invalid' | 'conflict' | 'saved'
export interface CrudUpdateModalProps {
recordName?: string
className?: string
}
const INPUT_CLASS =
'w-full rounded-lg border border-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 CrudUpdateModal({
recordName = 'Rotterdam DC · receiving hours',
className = '',
}: CrudUpdateModalProps) {
const uid = React.useId()
const [open, setOpen] = React.useState(true)
const [outcome, setOutcome] = React.useState<SaveOutcome>('idle')
const [attempt, setAttempt] = React.useState(0)
const [opens, setOpens] = React.useState('06:00')
const [closes, setCloses] = React.useState('22:00')
const [contact, setContact] = React.useState('goods-in@northwind.example')
const busy = outcome === 'saving'
function save(event: React.FormEvent) {
event.preventDefault()
if (busy) return
setOutcome('saving')
window.setTimeout(() => {
// A scripted demo of the three endings, not a claim about your API.
const next: SaveOutcome = attempt === 0 ? 'invalid' : attempt === 1 ? 'conflict' : 'saved'
setOutcome(next)
setAttempt((n) => n + 1)
}, 700)
}
return (
<section
className={`relative flex min-h-[28rem] items-center justify-center bg-muted/30 p-4 sm:p-6 ${className}`}
>
{open ? (
<div
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-title`}
className="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg"
>
<form onSubmit={save}>
<header className="flex items-start justify-between gap-3 border-b border-border p-4">
<div className="min-w-0">
<h2 id={`${uid}-title`} className="truncate text-base font-semibold">
Edit {recordName}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Changes apply to bookings made from now on.
</p>
</div>
<button
type="button"
disabled={busy}
onClick={() => setOpen(false)}
aria-label="Close"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-4 w-4" />
</button>
</header>
<div className="space-y-4 p-4">
{/* Announced where the user is, rather than moving focus. */}
{outcome === 'invalid' ? (
<div
role="alert"
className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm"
>
<p className="flex items-center gap-2 font-semibold text-destructive">
<TriangleAlert aria-hidden className="h-4 w-4" />
The server rejected this
</p>
<p className="mt-1 text-muted-foreground">
Receiving hours cannot end before they start. Everything you
typed is still here.
</p>
</div>
) : null}
{outcome === 'conflict' ? (
<div
role="alert"
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 text-sm"
>
<p className="flex items-center gap-2 font-semibold text-amber-700 dark:text-amber-400">
<RefreshCw aria-hidden className="h-4 w-4" />
Sam Okafor saved this two minutes ago
</p>
<p className="mt-1 text-muted-foreground">
Their version has closing at 20:00. This is not a validation
error — pick which one survives.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
setCloses('20:00')
setOutcome('idle')
}}
className="rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Load their version
</button>
<button
type="button"
onClick={() => setOutcome('idle')}
className="rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Keep mine and overwrite
</button>
</div>
</div>
) : null}
{outcome === 'saved' ? (
<p
role="status"
className="rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3 text-sm font-medium text-emerald-700 dark:text-emerald-400"
>
Saved. The modal stays open so you can check the values.
</p>
) : null}
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor={`${uid}-opens`} className="block text-sm font-medium">
Opens
</label>
<input
id={`${uid}-opens`}
type="time"
value={opens}
onChange={(e) => setOpens(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
<div>
<label htmlFor={`${uid}-closes`} className="block text-sm font-medium">
Closes
</label>
<input
id={`${uid}-closes`}
type="time"
value={closes}
onChange={(e) => setCloses(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
</div>
<div>
<label htmlFor={`${uid}-contact`} className="block text-sm font-medium">
Goods-in contact
</label>
<input
id={`${uid}-contact`}
type="email"
value={contact}
onChange={(e) => setContact(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
</div>
<footer className="flex items-center justify-end gap-2 border-t border-border p-4">
<button
type="button"
disabled={busy}
onClick={() => setOpen(false)}
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"
>
Cancel
</button>
<button
type="submit"
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
{busy ? (
<Loader2 aria-hidden className="h-4 w-4 motion-safe:animate-spin" />
) : null}
{busy ? 'Saving…' : 'Save changes'}
</button>
</footer>
</form>
</div>
) : (
<button
type="button"
onClick={() => {
setOpen(true)
setOutcome('idle')
}}
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Reopen the edit modal
</button>
)}
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/crud-update-modal.tsx and import it where you need the section:
import { CrudUpdateModal } from '@/components/crud-update-modal'1 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 |
|---|---|---|
recordName | string | 'Rotterdam DC · receiving hours' |
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 Modal — 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="relative flex min-h-[28rem] items-center justify-center bg-muted/30 p-4 sm:p-6 ">
<div role="dialog" aria-modal="true" aria-labelledby="_R_0_-title" class="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg">
<form>
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div class="min-w-0">
<h2 id="_R_0_-title" class="truncate text-base font-semibold">Edit Rotterdam DC · receiving hours</h2>
<p class="mt-0.5 text-sm text-muted-foreground">Changes apply to bookings made from now on.</p>
</div>
<button type="button" aria-label="Close" class="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<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-x h-4 w-4" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</header>
<div class="space-y-4 p-4">
<div class="grid grid-cols-2 gap-3">
<div>
<label for="_R_0_-opens" class="block text-sm font-medium">Opens</label>
<input id="_R_0_-opens" type="time" class="mt-1.5 w-full rounded-lg border border-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" value="06:00" />
</div>
<div>
<label for="_R_0_-closes" class="block text-sm font-medium">Closes</label>
<input id="_R_0_-closes" type="time" class="mt-1.5 w-full rounded-lg border border-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" value="22:00" />
</div>
</div>
<div>
<label for="_R_0_-contact" class="block text-sm font-medium">Goods-in contact</label>
<input id="_R_0_-contact" type="email" class="mt-1.5 w-full rounded-lg border border-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" value="goods-in@northwind.example" />
</div>
</div>
<footer class="flex items-center justify-end gap-2 border-t border-border p-4">
<button type="button" 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">Cancel</button>
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card">Save changes</button>
</footer>
</form>
</div>
</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.
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.
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.