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.
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.
components/crud-create-drawer.tsxnpx hoverlab add crud-create-drawer
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.
3 groups · 3 required fields
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <CrudCreateDrawer> — Create, in a side drawer, with the list still there.
*
* The middle of the three create surfaces, and the one that earns its place
* on a long form. A modal caps out at about four fields; a page throws away
* the list you are adding to. A drawer keeps both: the table stays legible on
* the start side, and the form gets vertical room and a scroll of its own.
*
* THE SCROLL BELONGS TO THE FORM, NOT THE PAGE
*
* The header and the action footer are pinned and only the fields scroll.
* That is the difference between a drawer and a modal that got tall: the
* primary action never leaves the screen, so nobody fills in nine fields and
* then hunts for Save.
*
* WHY THE STEP COUNTER AND NOT A WIZARD
*
* A wizard hides fields behind Next, which makes reviewing what you typed a
* navigation task and makes "which step had the error" a real question. The
* groups here are all rendered, all scrollable, and the counter is a progress
* report rather than a gate. Use `setup-wizard` when the steps genuinely
* depend on each other; use this when they are just long.
*
* WIDTH IS CAPPED. A drawer that covers the list defeats its own argument, so
* it stops at 30rem and goes full-width only below `md`, where the list is
* not visible anyway — `sm` (640px) is exactly wide enough for the drawer
* alone, leaving the list too little room for its own header row.
*
* ACCESSIBILITY: `role="dialog"` with `aria-modal="false"` — deliberately
* non-modal, because the list behind stays readable and claiming modality
* would be a lie. Each group is a `<fieldset>` with a `<legend>`; the
* required count in the footer is a live region, so a screen-reader user
* learns the form became submittable without polling the button.
*/
import * as React from 'react'
import { Check, Plus, X } from 'lucide-react'
export interface DrawerField {
id: string
label: string
type?: 'text' | 'email' | 'number' | 'date' | 'textarea' | 'select'
options?: string[]
required?: boolean
placeholder?: string
hint?: string
}
export interface DrawerGroup {
id: string
title: string
fields: DrawerField[]
}
export interface CrudCreateDrawerProps {
heading?: string
groups?: DrawerGroup[]
listRows?: string[]
className?: string
}
const DEFAULT_GROUPS: DrawerGroup[] = [
{
id: 'basics',
title: 'Basics',
fields: [
{ id: 'title', label: 'Asset name', required: true, placeholder: 'Forklift 04' },
{
id: 'category',
label: 'Category',
type: 'select',
required: true,
options: ['Material handling', 'Refrigeration', 'Vehicles', 'IT hardware'],
},
{ id: 'serial', label: 'Serial number', placeholder: 'TL-2291-A' },
],
},
{
id: 'location',
title: 'Location',
fields: [
{
id: 'site',
label: 'Site',
type: 'select',
required: true,
options: ['Rotterdam DC', 'Leeds DC', 'Pune DC'],
},
{ id: 'zone', label: 'Zone', placeholder: 'Aisle 12, bay 3' },
{
id: 'custodian',
label: 'Custodian',
type: 'select',
options: ['Rhea Patel', 'Sam Okafor', 'Jordan Lee'],
},
],
},
{
id: 'lifecycle',
title: 'Lifecycle',
fields: [
{ id: 'acquired', label: 'Acquired on', type: 'date' },
{ id: 'value', label: 'Book value', type: 'number', placeholder: '18400' },
{
id: 'service',
label: 'Service interval',
type: 'select',
options: ['Monthly', 'Quarterly', 'Every 6 months', 'Annually'],
hint: 'Drives the maintenance schedule. Changeable later.',
},
{
id: 'notes',
label: 'Commissioning notes',
type: 'textarea',
placeholder: 'Battery replaced before handover.',
},
],
},
]
const DEFAULT_ROWS = [
'Forklift 03 · Rotterdam DC',
'Chiller 11 · Leeds DC',
'Pallet wrapper 02 · Pune DC',
'Forklift 02 · Rotterdam DC',
'Scanner cart 07 · Leeds DC',
]
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 CrudCreateDrawer({
heading = 'New asset',
groups = DEFAULT_GROUPS,
listRows = DEFAULT_ROWS,
className = '',
}: CrudCreateDrawerProps) {
const uid = React.useId()
const [open, setOpen] = React.useState(true)
const [values, setValues] = React.useState<Record<string, string>>({
title: 'Forklift 04',
category: 'Material handling',
})
const required = groups.flatMap((g) => g.fields).filter((f) => f.required)
const outstanding = required.filter((f) => !(values[f.id] ?? '').trim())
return (
<section
className={`flex min-h-[32rem] gap-4 overflow-hidden bg-muted/30 p-4 ${className}`}
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
>
{/* The list is the reason this is a drawer and not a page. Held back
to `md`, not `sm`: at exactly 640px the drawer is already at its
full 30rem, and a 640px-wide list panel would get only the
scraps — too narrow for even its own "Assets"/"New" header row,
which then overflows into the drawer next to it. */}
<div className="hidden min-w-0 flex-1 md:block">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<h2 className="min-w-0 truncate text-sm font-semibold">Assets</h2>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex shrink-0 items-center gap-1.5 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"
>
<Plus aria-hidden className="h-3.5 w-3.5" />
New
</button>
</div>
<ul className="mt-3 space-y-1.5">
{listRows.map((row) => (
<li
key={row}
className="truncate rounded-lg border border-border bg-card/60 px-3 py-2.5 text-sm"
>
{row}
</li>
))}
</ul>
</div>
{open ? (
<div
role="dialog"
// Non-modal on purpose: the list stays usable behind it.
aria-modal="false"
aria-labelledby={`${uid}-heading`}
className="flex w-full flex-col rounded-xl border border-border bg-card text-card-foreground md:w-[30rem] md:max-w-full md:shrink-0"
>
<header className="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<h2 id={`${uid}-heading`} className="text-base font-semibold">
{heading}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{groups.length} groups · {required.length} required fields
</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-4 w-4" />
</button>
</header>
{/* Only this scrolls. The footer below never leaves the screen. */}
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-4">
{groups.map((group) => (
<fieldset key={group.id} className="border-0 p-0">
<legend className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{group.title}
</legend>
<div className="mt-3 space-y-3.5">
{group.fields.map((field) => {
const id = `${uid}-${field.id}`
const common = {
id,
required: field.required,
value: values[field.id] ?? '',
'aria-describedby': field.hint ? `${id}-hint` : undefined,
className: `mt-1.5 ${INPUT_CLASS}`,
}
return (
<div key={field.id}>
<label htmlFor={id} className="block text-sm font-medium">
{field.label}
{field.required ? null : (
<span className="ms-2 text-xs font-normal text-muted-foreground">
Optional
</span>
)}
</label>
{field.type === 'textarea' ? (
<textarea
{...common}
rows={3}
placeholder={field.placeholder}
onChange={(e) =>
setValues((v) => ({ ...v, [field.id]: e.target.value }))
}
/>
) : field.type === 'select' ? (
<select
{...common}
onChange={(e) =>
setValues((v) => ({ ...v, [field.id]: e.target.value }))
}
>
<option value="">Choose…</option>
{field.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : (
<input
{...common}
type={field.type ?? 'text'}
placeholder={field.placeholder}
onChange={(e) =>
setValues((v) => ({ ...v, [field.id]: e.target.value }))
}
/>
)}
{field.hint ? (
<p id={`${id}-hint`} className="mt-1.5 text-xs text-muted-foreground">
{field.hint}
</p>
) : null}
</div>
)
})}
</div>
</fieldset>
))}
</div>
<footer className="space-y-3 border-t border-border p-4">
<p
aria-live="polite"
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
{outstanding.length === 0 ? (
<>
<Check aria-hidden className="h-3.5 w-3.5 text-emerald-600" />
Every required field is filled.
</>
) : (
`${outstanding.length} required field${
outstanding.length === 1 ? '' : 's'
} left: ${outstanding.map((f) => f.label).join(', ')}.`
)}
</p>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Cancel
</button>
<button
type="button"
disabled={outstanding.length > 0}
className="min-w-0 flex-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
Create asset
</button>
</div>
</footer>
</div>
) : (
<div className="flex flex-1 items-center justify-center">
<button
type="button"
onClick={() => setOpen(true)}
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 drawer
</button>
</div>
)}
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/crud-create-drawer.tsx and import it where you need the section:
import { CrudCreateDrawer } from '@/components/crud-create-drawer'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 |
|---|---|---|
heading | string | 'New asset' |
groups | DrawerGroup[] | DEFAULT_GROUPS |
listRows | string[] | DEFAULT_ROWS |
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 Drawer — 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="flex min-h-[32rem] gap-4 overflow-hidden bg-muted/30 p-4 ">
<div class="hidden min-w-0 flex-1 md:block">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-3">
<h2 class="min-w-0 truncate text-sm font-semibold">Assets</h2>
<button type="button" class="inline-flex shrink-0 items-center gap-1.5 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"><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-3.5 w-3.5" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>New</button>
</div>
<ul class="mt-3 space-y-1.5">
<li class="truncate rounded-lg border border-border bg-card/60 px-3 py-2.5 text-sm">Forklift 03 · Rotterdam DC</li>
<li class="truncate rounded-lg border border-border bg-card/60 px-3 py-2.5 text-sm">Chiller 11 · Leeds DC</li>
<li class="truncate rounded-lg border border-border bg-card/60 px-3 py-2.5 text-sm">Pallet wrapper 02 · Pune DC</li>
<li class="truncate rounded-lg border border-border bg-card/60 px-3 py-2.5 text-sm">Forklift 02 · Rotterdam DC</li>
<li class="truncate rounded-lg border border-border bg-card/60 px-3 py-2.5 text-sm">Scanner cart 07 · Leeds DC</li>
</ul>
</div>
<div role="dialog" aria-modal="false" aria-labelledby="_R_0_-heading" class="flex w-full flex-col rounded-xl border border-border bg-card text-card-foreground md:w-[30rem] md:max-w-full md:shrink-0">
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<h2 id="_R_0_-heading" class="text-base font-semibold">New asset</h2>
<p class="mt-0.5 text-sm text-muted-foreground">3 groups · 3 required fields</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 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="min-h-0 flex-1 space-y-6 overflow-y-auto p-4">
<fieldset class="border-0 p-0">
<legend class="text-xs font-bold uppercase tracking-wider text-muted-foreground">Basics</legend>
<div class="mt-3 space-y-3.5">
<div>
<label for="_R_0_-title" class="block text-sm font-medium">Asset name</label>
<input id="_R_0_-title" required="" 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" type="text" placeholder="Forklift 04" value="Forklift 04" />
</div>
<div>
<label for="_R_0_-category" class="block text-sm font-medium">Category</label>
<select id="_R_0_-category" required="" 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 value="">Choose…</option>
<option value="Material handling" selected="">Material handling</option>
<option value="Refrigeration">Refrigeration</option>
<option value="Vehicles">Vehicles</option>
<option value="IT hardware">IT hardware</option>
</select>
</div>
<div>
<label for="_R_0_-serial" class="block text-sm font-medium">Serial number<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<input id="_R_0_-serial" 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" type="text" placeholder="TL-2291-A" value="" />
</div>
</div>
</fieldset>
<fieldset class="border-0 p-0">
<legend class="text-xs font-bold uppercase tracking-wider text-muted-foreground">Location</legend>
<div class="mt-3 space-y-3.5">
<div>
<label for="_R_0_-site" class="block text-sm font-medium">Site</label>
<select id="_R_0_-site" required="" 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 value="" selected="">Choose…</option>
<option value="Rotterdam DC">Rotterdam DC</option>
<option value="Leeds DC">Leeds DC</option>
<option value="Pune DC">Pune DC</option>
</select>
</div>
<div>
<label for="_R_0_-zone" class="block text-sm font-medium">Zone<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<input id="_R_0_-zone" 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" type="text" placeholder="Aisle 12, bay 3" value="" />
</div>
<div>
<label for="_R_0_-custodian" class="block text-sm font-medium">Custodian<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<select id="_R_0_-custodian" 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 value="" selected="">Choose…</option>
<option value="Rhea Patel">Rhea Patel</option>
<option value="Sam Okafor">Sam Okafor</option>
<option value="Jordan Lee">Jordan Lee</option>
</select>
</div>
</div>
</fieldset>
<fieldset class="border-0 p-0">
<legend class="text-xs font-bold uppercase tracking-wider text-muted-foreground">Lifecycle</legend>
<div class="mt-3 space-y-3.5">
<div>
<label for="_R_0_-acquired" class="block text-sm font-medium">Acquired on<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<input id="_R_0_-acquired" 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" type="date" value="" />
</div>
<div>
<label for="_R_0_-value" class="block text-sm font-medium">Book value<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<input id="_R_0_-value" 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" type="number" placeholder="18400" value="" />
</div>
<div>
<label for="_R_0_-service" class="block text-sm font-medium">Service interval<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<select id="_R_0_-service" aria-describedby="_R_0_-service-hint" 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 value="" selected="">Choose…</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Every 6 months">Every 6 months</option>
<option value="Annually">Annually</option>
</select>
<p id="_R_0_-service-hint" class="mt-1.5 text-xs text-muted-foreground">Drives the maintenance schedule. Changeable later.</p>
</div>
<div>
<label for="_R_0_-notes" class="block text-sm font-medium">Commissioning notes<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<textarea id="_R_0_-notes" 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" rows="3" placeholder="Battery replaced before handover."></textarea>
</div>
</div>
</fieldset>
</div>
<footer class="space-y-3 border-t border-border p-4">
<p aria-live="polite" class="flex items-center gap-1.5 text-xs text-muted-foreground">1 required field left: Site.</p>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="rounded-lg border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Cancel</button>
<button type="button" disabled="" class="min-w-0 flex-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card">Create asset</button>
</div>
</footer>
</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.
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.