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.
One change applied to many records that disagree - mixed values shown as mixed, untouched fields never written, and skipped rows counted before you commit.
components/crud-bulk-edit-drawer.tsxnpx hoverlab add crud-bulk-edit-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.
Only the fields you tick are written. Everything else is left exactly as it is.
Mixed — 3 different values across the selection
All 17 are currently Rotterdam DC
Mixed — 2 different values across the selection
All 17 are currently Standard
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <CrudBulkEditDrawer> — one change, applied to many records.
*
* `data-table-bulk-actions` covers the verbs that need no input: archive,
* export, assign to me. This covers the one that does — editing a field
* across a selection — and it is a different problem, because the selected
* records do not agree with each other.
*
* MIXED VALUES ARE THE WHOLE DESIGN PROBLEM
*
* Seventeen orders are selected. Nine are Net 30, five are Net 60, three are
* On receipt. A single select showing "Net 30" is a lie that overwrites
* fourteen records the moment someone presses Save. So every field here has
* three states, not two:
*
* untouched left alone entirely — the default, and it must be the default
* mixed shown as "Mixed (3 values)" until deliberately set
* set one value, applied to all
*
* The checkbox beside each field is what moves it from untouched to set. No
* checkbox, no write — which means a bulk edit can never change a field
* nobody looked at, and that is the property worth protecting.
*
* THE COUNT IS ON THE BUTTON AND IT IS ARITHMETIC
*
* "Apply to 17 orders" tells you the blast radius. "Save" does not. The
* button also excludes records the change cannot apply to — three of the
* seventeen are already dispatched and their terms are frozen — and says so
* above itself rather than failing halfway and reporting partial success.
*
* ACCESSIBILITY: each field is a group whose enable checkbox is labelled with
* the field name ("Change payment terms"), so a screen reader hears what the
* checkbox does rather than "checkbox, checked". The excluded-records notice
* is a polite live region because it changes as fields are enabled.
*/
import * as React from 'react'
import { Check, Info, X } from 'lucide-react'
export interface BulkField {
id: string
label: string
options: string[]
/** Distinct values across the selection. More than one renders "Mixed". */
currentValues: string[]
/** Records this field cannot be changed on, and why. */
frozen?: { count: number; reason: string }
}
export interface CrudBulkEditDrawerProps {
selectedCount?: number
recordNoun?: string
fields?: BulkField[]
className?: string
}
const DEFAULT_FIELDS: BulkField[] = [
{
id: 'terms',
label: 'Payment terms',
options: ['Net 14', 'Net 30', 'Net 60', 'On receipt'],
currentValues: ['Net 30', 'Net 60', 'On receipt'],
frozen: { count: 3, reason: 'already dispatched — terms are frozen at dispatch' },
},
{
id: 'site',
label: 'Delivery site',
options: ['Rotterdam DC', 'Leeds DC', 'Pune DC'],
currentValues: ['Rotterdam DC'],
},
{
id: 'approver',
label: 'Approver',
options: ['Rhea Patel', 'Sam Okafor', 'Jordan Lee'],
currentValues: ['Rhea Patel', 'Sam Okafor'],
},
{
id: 'priority',
label: 'Priority',
options: ['Standard', 'Expedited'],
currentValues: ['Standard'],
},
]
const INPUT_CLASS =
'w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors disabled:opacity-50 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring'
export function CrudBulkEditDrawer({
selectedCount = 17,
recordNoun = 'orders',
fields = DEFAULT_FIELDS,
className = '',
}: CrudBulkEditDrawerProps) {
const uid = React.useId()
const [enabled, setEnabled] = React.useState<Record<string, boolean>>({})
const [values, setValues] = React.useState<Record<string, string>>({})
const active = fields.filter((f) => enabled[f.id])
// The largest frozen count among the enabled fields — a record excluded by
// one field is excluded from the write for that field only, and the honest
// headline number is the worst case.
const excluded = active.reduce((worst, f) => Math.max(worst, f.frozen?.count ?? 0), 0)
const applyTo = selectedCount - excluded
return (
<section className={`flex min-h-[30rem] justify-end bg-muted/30 p-4 ${className}`}>
<div
role="dialog"
aria-modal="false"
aria-labelledby={`${uid}-title`}
className="flex w-full flex-col rounded-xl border border-border bg-card text-card-foreground sm:w-[28rem]"
>
<header className="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<h2 id={`${uid}-title`} className="text-base font-semibold">
Edit {selectedCount} {recordNoun}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Only the fields you tick are written. Everything else is left
exactly as it is.
</p>
</div>
<button
type="button"
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>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{fields.map((field) => {
const on = Boolean(enabled[field.id])
const mixed = field.currentValues.length > 1
const id = `${uid}-${field.id}`
return (
<div
key={field.id}
className={`rounded-xl border p-3 transition-colors ${
on ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className="flex items-start gap-2.5">
<input
type="checkbox"
id={`${id}-enable`}
checked={on}
onChange={(e) =>
setEnabled((s) => ({ ...s, [field.id]: e.target.checked }))
}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-border accent-primary"
/>
<div className="min-w-0 flex-1">
<label htmlFor={`${id}-enable`} className="text-sm font-medium">
Change {field.label}
</label>
<p className="mt-0.5 text-xs text-muted-foreground">
{mixed
? `Mixed — ${field.currentValues.length} different values across the selection`
: `All ${selectedCount} are currently ${field.currentValues[0]}`}
</p>
<select
id={id}
aria-label={field.label}
disabled={!on}
value={values[field.id] ?? ''}
onChange={(e) =>
setValues((v) => ({ ...v, [field.id]: e.target.value }))
}
className={`mt-2 ${INPUT_CLASS}`}
>
<option value="">
{mixed ? `Mixed (${field.currentValues.length} values)` : 'Unchanged'}
</option>
{field.options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{on && field.frozen ? (
<p className="mt-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-400">
<Info aria-hidden className="mt-0.5 h-3 w-3 shrink-0" />
{field.frozen.count} of the {selectedCount} are{' '}
{field.frozen.reason}. They are skipped.
</p>
) : null}
</div>
</div>
</div>
)
})}
</div>
<footer className="space-y-3 border-t border-border p-4">
<p aria-live="polite" className="text-xs text-muted-foreground">
{active.length === 0
? 'Nothing selected to change yet.'
: `${active.length} field${active.length === 1 ? '' : 's'} will be written${
excluded > 0 ? `, skipping ${excluded} frozen ${recordNoun}` : ''
}.`}
</p>
<div className="flex items-center gap-2">
<button
type="button"
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={active.length === 0}
className="inline-flex flex-1 items-center justify-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-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<Check aria-hidden className="h-4 w-4" />
Apply to {applyTo} {recordNoun}
</button>
</div>
</footer>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/crud-bulk-edit-drawer.tsx and import it where you need the section:
import { CrudBulkEditDrawer } from '@/components/crud-bulk-edit-drawer'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 |
|---|---|---|
selectedCount | number | 17 |
recordNoun | string | 'orders' |
fields | BulkField[] | 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.
<!--
Bulk Edit 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-[30rem] justify-end bg-muted/30 p-4 ">
<div role="dialog" aria-modal="false" aria-labelledby="_R_0_-title" class="flex w-full flex-col rounded-xl border border-border bg-card text-card-foreground sm:w-[28rem]">
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<h2 id="_R_0_-title" class="text-base font-semibold">Edit 17 orders</h2>
<p class="mt-0.5 text-sm text-muted-foreground">Only the fields you tick are written. Everything else is left exactly as it is.</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-4 overflow-y-auto p-4">
<div class="rounded-xl border p-3 transition-colors border-border">
<div class="flex items-start gap-2.5">
<input type="checkbox" id="_R_0_-terms-enable" class="mt-0.5 h-4 w-4 shrink-0 rounded border-border accent-primary" />
<div class="min-w-0 flex-1">
<label for="_R_0_-terms-enable" class="text-sm font-medium">Change Payment terms</label>
<p class="mt-0.5 text-xs text-muted-foreground">Mixed — 3 different values across the selection</p>
<select id="_R_0_-terms" aria-label="Payment terms" disabled="" class="mt-2 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors disabled:opacity-50 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option value="" selected="">Mixed (3 values)</option>
<option value="Net 14">Net 14</option>
<option value="Net 30">Net 30</option>
<option value="Net 60">Net 60</option>
<option value="On receipt">On receipt</option>
</select>
</div>
</div>
</div>
<div class="rounded-xl border p-3 transition-colors border-border">
<div class="flex items-start gap-2.5">
<input type="checkbox" id="_R_0_-site-enable" class="mt-0.5 h-4 w-4 shrink-0 rounded border-border accent-primary" />
<div class="min-w-0 flex-1">
<label for="_R_0_-site-enable" class="text-sm font-medium">Change Delivery site</label>
<p class="mt-0.5 text-xs text-muted-foreground">All 17 are currently Rotterdam DC</p>
<select id="_R_0_-site" aria-label="Delivery site" disabled="" class="mt-2 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors disabled:opacity-50 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option value="" selected="">Unchanged</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>
</div>
<div class="rounded-xl border p-3 transition-colors border-border">
<div class="flex items-start gap-2.5">
<input type="checkbox" id="_R_0_-approver-enable" class="mt-0.5 h-4 w-4 shrink-0 rounded border-border accent-primary" />
<div class="min-w-0 flex-1">
<label for="_R_0_-approver-enable" class="text-sm font-medium">Change Approver</label>
<p class="mt-0.5 text-xs text-muted-foreground">Mixed — 2 different values across the selection</p>
<select id="_R_0_-approver" aria-label="Approver" disabled="" class="mt-2 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors disabled:opacity-50 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option value="" selected="">Mixed (2 values)</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>
</div>
<div class="rounded-xl border p-3 transition-colors border-border">
<div class="flex items-start gap-2.5">
<input type="checkbox" id="_R_0_-priority-enable" class="mt-0.5 h-4 w-4 shrink-0 rounded border-border accent-primary" />
<div class="min-w-0 flex-1">
<label for="_R_0_-priority-enable" class="text-sm font-medium">Change Priority</label>
<p class="mt-0.5 text-xs text-muted-foreground">All 17 are currently Standard</p>
<select id="_R_0_-priority" aria-label="Priority" disabled="" class="mt-2 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors disabled:opacity-50 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option value="" selected="">Unchanged</option>
<option value="Standard">Standard</option>
<option value="Expedited">Expedited</option>
</select>
</div>
</div>
</div>
</div>
<footer class="space-y-3 border-t border-border p-4">
<p aria-live="polite" class="text-xs text-muted-foreground">Nothing selected to change yet.</p>
<div class="flex 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="inline-flex flex-1 items-center justify-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-40 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-check h-4 w-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>Apply to 17 orders</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.
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.
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.