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.
Three fields over the list they add to, with the new row appearing behind the dialog rather than after it closes.
components/crud-create-modal.tsxnpx hoverlab add crud-create-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'
/**
* <CrudCreateModal> — Create, in a modal, over the list it adds to.
*
* The modal is the right surface for a create form only when the whole form
* fits without scrolling. That is the test, and it is worth stating because
* the failure mode is so common: a modal that scrolls has all the costs of a
* page — lost context, a second scrollbar, a footer that drifts — and none of
* the benefit, which is that you can see what you are adding to behind it.
*
* Three or four fields is about the ceiling on a laptop. Past that,
* `crud-create-drawer` keeps the list visible while giving the form room, and
* `crud-create-form` gives up on the list entirely and gains a guidance rail.
*
* THE ROW APPEARS BEHIND THE MODAL, NOT AFTER IT CLOSES
*
* Submit and the record is pushed into the list underneath, highlighted,
* while the dialog is still open. That is the entire argument for creating in
* a modal and a surprising number of implementations throw it away by closing
* first — at which point the user is looking at a list and hoping.
*
* WHY THE SCRIM IS INSIDE THE BLOCK
*
* This renders as a positioned overlay within its own section rather than
* through `showModal()` and the browser's top layer. A block is a section you
* paste into a page, and a section that hoists itself above everything on the
* page the moment it mounts is not one. Wire it to your dialog primitive — or
* to a native `<dialog>`, which is what `confirm-dialog` in this catalog
* demonstrates — and you inherit the focus trap and Escape for free. What is
* here is the layout and the copy; the two are separable on purpose.
*
* ACCESSIBILITY: `role="dialog"` with `aria-modal`, named by its own heading,
* and the list behind is `aria-hidden` and inert to pointer events while the
* dialog is up — the two halves of "modal" that markup can honestly claim on
* its own. The busy state disables both buttons rather than only the primary,
* because a cancel mid-request leaves the user unsure whether the record
* exists.
*/
import * as React from 'react'
import { Check, Loader2, Plus, X } from 'lucide-react'
export interface CreateModalRow {
id: string
name: string
owner: string
/** Rendered highlighted — the row this dialog just added. */
fresh?: boolean
}
export interface CrudCreateModalProps {
title?: string
rows?: CreateModalRow[]
/** Whether the dialog starts open. Defaults true so the preview shows it. */
defaultOpen?: boolean
className?: string
}
const DEFAULT_ROWS: CreateModalRow[] = [
{ id: 'PRJ-114', name: 'Warehouse relabelling', owner: 'Rhea Patel' },
{ id: 'PRJ-113', name: 'Q4 supplier audit', owner: 'Sam Okafor' },
{ id: 'PRJ-112', name: 'Returns triage rewrite', owner: 'Jordan Lee' },
]
const INPUT_CLASS =
'w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring'
export function CrudCreateModal({
title = 'New project',
rows = DEFAULT_ROWS,
defaultOpen = true,
className = '',
}: CrudCreateModalProps) {
// Per-instance ids: a literal id in a reusable component collides with the
// second copy on the page, and a <label> then resolves to whichever input
// rendered first.
const uid = React.useId()
const [open, setOpen] = React.useState(defaultOpen)
const [list, setList] = React.useState<CreateModalRow[]>(rows)
const [name, setName] = React.useState('Supplier onboarding pack')
const [owner, setOwner] = React.useState('Rhea Patel')
const [busy, setBusy] = React.useState(false)
const [added, setAdded] = React.useState(0)
function create(event: React.FormEvent) {
event.preventDefault()
if (!name.trim() || busy) return
setBusy(true)
window.setTimeout(() => {
setList((prev) => [
{
id: `PRJ-${115 + added}`,
name: name.trim(),
owner,
fresh: true,
},
...prev.map((row) => ({ ...row, fresh: false })),
])
setAdded((n) => n + 1)
setBusy(false)
setName('')
}, 550)
}
return (
<section
className={`relative min-h-[26rem] overflow-hidden bg-muted/30 p-4 sm:p-6 ${className}`}
onKeyDown={(event) => {
if (event.key === 'Escape' && !busy) setOpen(false)
}}
>
{/* The list the dialog adds to. Kept on screen, dimmed, and taken out
of the accessibility tree while the dialog is up. */}
<div
aria-hidden={open || undefined}
className={`mx-auto max-w-3xl rounded-2xl border border-border bg-card p-5 transition-opacity ${
open ? 'pointer-events-none opacity-50' : ''
}`}
>
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-semibold">Projects</h2>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<Plus aria-hidden className="h-4 w-4" />
New project
</button>
</div>
<ul className="mt-4 divide-y divide-border/70">
{list.map((row) => (
<li
key={row.id}
className={`flex items-center justify-between gap-3 rounded-lg px-2 py-2.5 text-sm transition-colors ${
row.fresh ? 'bg-primary/10' : ''
}`}
>
<span className="min-w-0">
<span className="block truncate font-medium">{row.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{row.id} · {row.owner}
</span>
</span>
{row.fresh ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-primary/15 px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-primary">
<Check aria-hidden className="h-3 w-3" />
Added
</span>
) : null}
</li>
))}
</ul>
</div>
{open ? (
<div className="absolute inset-0 flex items-center justify-center bg-foreground/25 p-4 backdrop-blur-[2px]">
<div
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-title`}
aria-describedby={`${uid}-note`}
className="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg"
>
<form onSubmit={create} className="p-5">
<div className="flex items-start justify-between gap-4">
<div>
{title ? (
<h3 id={`${uid}-title`} data-stress-ignore className="text-base font-semibold">
{title}
</h3>
) : null}
<p id={`${uid}-note`} className="mt-1 text-sm text-muted-foreground">
Three fields. A modal that scrolls should have been a drawer.
</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>
</div>
<div className="mt-5 space-y-4">
<div>
<label htmlFor={`${uid}-name`} className="block text-sm font-medium">
Project name
</label>
<input
id={`${uid}-name`}
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Returns triage rewrite"
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
<div>
<label htmlFor={`${uid}-owner`} className="block text-sm font-medium">
Owner
</label>
<select
id={`${uid}-owner`}
value={owner}
onChange={(e) => setOwner(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
>
<option>Rhea Patel</option>
<option>Sam Okafor</option>
<option>Jordan Lee</option>
</select>
</div>
<div>
<label
htmlFor={`${uid}-visibility`}
className="block text-sm font-medium"
>
Visibility
</label>
<select id={`${uid}-visibility`} className={`mt-1.5 ${INPUT_CLASS}`}>
<option>Everyone in the workspace</option>
<option>Invite only</option>
</select>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<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-50 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 ? 'Creating…' : 'Create project'}
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/crud-create-modal.tsx and import it where you need the section:
import { CrudCreateModal } from '@/components/crud-create-modal'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 |
|---|---|---|
title | string | 'New project' |
rows | CreateModalRow[] | DEFAULT_ROWS |
defaultOpenWhether the dialog starts open. Defaults true so the preview shows it. | boolean | true |
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.
<!--
Create 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 min-h-[26rem] overflow-hidden bg-muted/30 p-4 sm:p-6 ">
<div aria-hidden="true" class="mx-auto max-w-3xl rounded-2xl border border-border bg-card p-5 transition-opacity pointer-events-none opacity-50">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold">Projects</h2>
<button type="button" class="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"><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-plus h-4 w-4" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>New project</button>
</div>
<ul class="mt-4 divide-y divide-border/70">
<li class="flex items-center justify-between gap-3 rounded-lg px-2 py-2.5 text-sm transition-colors ">
<span class="min-w-0">
<span class="block truncate font-medium">Warehouse relabelling</span>
<span class="block truncate text-xs text-muted-foreground">PRJ-114 · Rhea Patel</span>
</span>
</li>
<li class="flex items-center justify-between gap-3 rounded-lg px-2 py-2.5 text-sm transition-colors ">
<span class="min-w-0">
<span class="block truncate font-medium">Q4 supplier audit</span>
<span class="block truncate text-xs text-muted-foreground">PRJ-113 · Sam Okafor</span>
</span>
</li>
<li class="flex items-center justify-between gap-3 rounded-lg px-2 py-2.5 text-sm transition-colors ">
<span class="min-w-0">
<span class="block truncate font-medium">Returns triage rewrite</span>
<span class="block truncate text-xs text-muted-foreground">PRJ-112 · Jordan Lee</span>
</span>
</li>
</ul>
</div>
<div class="absolute inset-0 flex items-center justify-center bg-foreground/25 p-4 backdrop-blur-[2px]">
<div role="dialog" aria-modal="true" aria-labelledby="_R_0_-title" aria-describedby="_R_0_-note" class="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg">
<form class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<h3 id="_R_0_-title" data-stress-ignore="true" class="text-base font-semibold">New project</h3>
<p id="_R_0_-note" class="mt-1 text-sm text-muted-foreground">Three fields. A modal that scrolls should have been a drawer.</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>
</div>
<div class="mt-5 space-y-4">
<div>
<label for="_R_0_-name" class="block text-sm font-medium">Project name</label>
<input id="_R_0_-name" required="" placeholder="Returns triage rewrite" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="Supplier onboarding pack" />
</div>
<div>
<label for="_R_0_-owner" class="block text-sm font-medium">Owner</label>
<select id="_R_0_-owner" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option selected="">Rhea Patel</option>
<option>Sam Okafor</option>
<option>Jordan Lee</option>
</select>
</div>
<div>
<label for="_R_0_-visibility" class="block text-sm font-medium">Visibility</label>
<select id="_R_0_-visibility" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option>Everyone in the workspace</option>
<option>Invite only</option>
</select>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<button type="button" class="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50 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">Create project</button>
</div>
</form>
</div>
</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.
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 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.