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 verification modal from a table row: copyable identifiers, no editing, nothing scrolling, and the row still marked behind it.
components/crud-read-modal.tsxnpx hoverlab add crud-read-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.
Northwind Trading · £18,900
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <CrudReadModal> — Read, in a modal: the quick look from a table row.
*
* Three read surfaces, three different jobs, and they are not
* interchangeable:
*
* page (`crud-read-page`) study one record properly
* drawer (`drawer-record-detail`) compare several without losing the list
* modal (this) check one fact and get out
*
* The modal is the one people build first and justify last. Its actual job is
* *verification* — someone is mid-task in the table, needs to confirm one
* thing about a row, and must return to exactly where they were. Everything
* here follows from that.
*
* WHAT FOLLOWS FROM IT
*
* - **Copy buttons on the identifiers.** The reason to open a row is very
* often to paste its id somewhere else. A read view that makes you select
* text by hand has missed its own use case.
* - **No editing.** Not a disabled edit field, not an inline pencil. Edit is
* a link that leaves. A modal that can both show and change is a modal
* where "did I just alter this record" is a real question.
* - **Nothing scrolls.** If the record does not fit, this is the wrong
* surface — the field list is capped and the overflow is a link to the
* full record rather than an inner scrollbar.
*
* WHY THE ROW STAYS HIGHLIGHTED. The table behind keeps its selected row
* marked, so closing lands the eye back where it started. Dropping the
* highlight is how a quick look turns into "where was I".
*
* ACCESSIBILITY: `role="dialog"` with `aria-modal`, named by the record's own
* title; the table behind is `aria-hidden` and pointer-inert while it is up.
* The copy button's confirmation is a live region rather than a tooltip, so
* it is announced rather than merely seen.
*/
import * as React from 'react'
import { Check, Copy, ExternalLink, X } from 'lucide-react'
export interface QuickLookRow {
id: string
name: string
meta: string
fields: { label: string; value: string; mono?: boolean; copyable?: boolean }[]
}
export interface CrudReadModalProps {
rows?: QuickLookRow[]
className?: string
}
const DEFAULT_ROWS: QuickLookRow[] = [
{
id: 'inv-841',
name: 'INV-2026-0841',
meta: 'Northwind Trading · £18,900',
fields: [
{ label: 'Reference', value: 'INV-2026-0841', mono: true, copyable: true },
{ label: 'Supplier', value: 'Northwind Trading Ltd' },
{ label: 'Amount', value: '£18,900.00' },
{ label: 'Raised', value: '31 July 2026' },
{ label: 'Paid', value: '31 August 2026' },
{ label: 'Bank reference', value: 'BACS-77120934', mono: true, copyable: true },
],
},
{
id: 'inv-839',
name: 'INV-2026-0839',
meta: 'Contoso Logistics · £4,120',
fields: [
{ label: 'Reference', value: 'INV-2026-0839', mono: true, copyable: true },
{ label: 'Supplier', value: 'Contoso Logistics BV' },
{ label: 'Amount', value: '£4,120.00' },
{ label: 'Raised', value: '24 July 2026' },
{ label: 'Paid', value: 'Overdue by 21 days' },
{ label: 'Bank reference', value: '—' },
],
},
{
id: 'inv-836',
name: 'INV-2026-0836',
meta: 'Fabrikam Parts · £912',
fields: [
{ label: 'Reference', value: 'INV-2026-0836', mono: true, copyable: true },
{ label: 'Supplier', value: 'Fabrikam Parts GmbH' },
{ label: 'Amount', value: '£912.00' },
{ label: 'Raised', value: '18 July 2026' },
{ label: 'Paid', value: '2 August 2026' },
{ label: 'Bank reference', value: 'BACS-77118201', mono: true, copyable: true },
],
},
]
export function CrudReadModal({ rows = DEFAULT_ROWS, className = '' }: CrudReadModalProps) {
const uid = React.useId()
const [openId, setOpenId] = React.useState<string | null>(rows[0]?.id ?? null)
const [copied, setCopied] = React.useState<string | null>(null)
const record = rows.find((row) => row.id === openId)
async function copy(label: string, value: string) {
// Feature-detected: the clipboard API is absent over plain http and in
// some embedded webviews, and an unguarded call throws there.
try {
await navigator.clipboard?.writeText(value)
setCopied(label)
window.setTimeout(() => setCopied(null), 1600)
} catch {
/* A failed copy is not worth an error state in a read view. */
}
}
return (
<section
className={`relative min-h-[26rem] overflow-hidden bg-muted/30 p-4 sm:p-6 ${className}`}
onKeyDown={(event) => {
if (event.key === 'Escape') setOpenId(null)
}}
>
<div
aria-hidden={record ? true : undefined}
className={`mx-auto max-w-3xl overflow-hidden rounded-2xl border border-border bg-card transition-opacity ${
record ? 'pointer-events-none opacity-50' : ''
}`}
>
<h2 className="border-b border-border px-4 py-3 text-sm font-semibold">
Invoices
</h2>
<ul className="divide-y divide-border/70">
{rows.map((row) => (
<li key={row.id}>
<button
type="button"
onClick={() => setOpenId(row.id)}
// The selected row stays marked behind the modal, so closing
// lands the eye back where it started.
aria-current={openId === row.id ? 'true' : undefined}
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-start text-sm transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
openId === row.id ? '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.meta}
</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground">Quick look</span>
</button>
</li>
))}
</ul>
</div>
{record ? (
<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`}
className="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg"
>
<header className="flex items-start justify-between gap-3 border-b border-border p-4">
<div className="min-w-0">
<h3 id={`${uid}-title`} className="truncate text-base font-semibold">
{record.name}
</h3>
<p className="mt-0.5 truncate text-sm text-muted-foreground">
{record.meta}
</p>
</div>
<button
type="button"
onClick={() => setOpenId(null)}
aria-label="Close quick look"
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>
<dl className="divide-y divide-border/70">
{record.fields.map((field) => (
<div
key={field.label}
className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm"
>
<dt className="text-muted-foreground">{field.label}</dt>
<dd className="flex min-w-0 items-center gap-1.5">
<span className={field.mono ? 'truncate font-mono text-xs' : 'truncate'}>
{field.value}
</span>
{field.copyable ? (
<button
type="button"
onClick={() => copy(field.label, field.value)}
aria-label={`Copy ${field.label}`}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{copied === field.label ? (
<Check aria-hidden className="h-3.5 w-3.5 text-emerald-600" />
) : (
<Copy aria-hidden className="h-3.5 w-3.5" />
)}
</button>
) : null}
</dd>
</div>
))}
</dl>
{/* Announced, not merely shown. */}
<p aria-live="polite" className="sr-only">
{copied ? `${copied} copied to the clipboard` : ''}
</p>
<footer className="flex items-center justify-between gap-2 border-t border-border p-4">
{/* Edit leaves. A read modal that can also write is a modal
where "did I just change this" is a real question. */}
<a
href="#"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary underline-offset-4 hover:underline"
>
Open the full record
<ExternalLink aria-hidden className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={() => setOpenId(null)}
className="rounded-lg border border-border 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"
>
Back to the list
</button>
</footer>
</div>
</div>
) : null}
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/crud-read-modal.tsx and import it where you need the section:
import { CrudReadModal } from '@/components/crud-read-modal'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 |
|---|---|---|
rows | QuickLookRow[] | DEFAULT_ROWS, className = '' |
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.
<!--
Record Quick Look — 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 overflow-hidden rounded-2xl border border-border bg-card transition-opacity pointer-events-none opacity-50">
<h2 class="border-b border-border px-4 py-3 text-sm font-semibold">Invoices</h2>
<ul class="divide-y divide-border/70">
<li>
<button type="button" aria-current="true" class="flex w-full items-center justify-between gap-3 px-4 py-3 text-start text-sm transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring bg-primary/10">
<span class="min-w-0">
<span class="block truncate font-medium">INV-2026-0841</span>
<span class="block truncate text-xs text-muted-foreground">Northwind Trading · £18,900</span>
</span>
<span class="shrink-0 text-xs text-muted-foreground">Quick look</span>
</button>
</li>
<li>
<button type="button" class="flex w-full items-center justify-between gap-3 px-4 py-3 text-start text-sm transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ">
<span class="min-w-0">
<span class="block truncate font-medium">INV-2026-0839</span>
<span class="block truncate text-xs text-muted-foreground">Contoso Logistics · £4,120</span>
</span>
<span class="shrink-0 text-xs text-muted-foreground">Quick look</span>
</button>
</li>
<li>
<button type="button" class="flex w-full items-center justify-between gap-3 px-4 py-3 text-start text-sm transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ">
<span class="min-w-0">
<span class="block truncate font-medium">INV-2026-0836</span>
<span class="block truncate text-xs text-muted-foreground">Fabrikam Parts · £912</span>
</span>
<span class="shrink-0 text-xs text-muted-foreground">Quick look</span>
</button>
</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" class="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg">
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div class="min-w-0">
<h3 id="_R_0_-title" class="truncate text-base font-semibold">INV-2026-0841</h3>
<p class="mt-0.5 truncate text-sm text-muted-foreground">Northwind Trading · £18,900</p>
</div>
<button type="button" aria-label="Close quick look" 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>
<dl class="divide-y divide-border/70">
<div class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
<dt class="text-muted-foreground">Reference</dt>
<dd class="flex min-w-0 items-center gap-1.5">
<span class="truncate font-mono text-xs">INV-2026-0841</span>
<button type="button" aria-label="Copy Reference" class="rounded p-1 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-copy h-3.5 w-3.5" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
</button>
</dd>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
<dt class="text-muted-foreground">Supplier</dt>
<dd class="flex min-w-0 items-center gap-1.5">
<span class="truncate">Northwind Trading Ltd</span>
</dd>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
<dt class="text-muted-foreground">Amount</dt>
<dd class="flex min-w-0 items-center gap-1.5">
<span class="truncate">£18,900.00</span>
</dd>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
<dt class="text-muted-foreground">Raised</dt>
<dd class="flex min-w-0 items-center gap-1.5">
<span class="truncate">31 July 2026</span>
</dd>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
<dt class="text-muted-foreground">Paid</dt>
<dd class="flex min-w-0 items-center gap-1.5">
<span class="truncate">31 August 2026</span>
</dd>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
<dt class="text-muted-foreground">Bank reference</dt>
<dd class="flex min-w-0 items-center gap-1.5">
<span class="truncate font-mono text-xs">BACS-77120934</span>
<button type="button" aria-label="Copy Bank reference" class="rounded p-1 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-copy h-3.5 w-3.5" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
</button>
</dd>
</div>
</dl>
<p aria-live="polite" class="sr-only"></p>
<footer class="flex items-center justify-between gap-2 border-t border-border p-4">
<a href="#" class="inline-flex items-center gap-1.5 text-sm font-medium text-primary underline-offset-4 hover:underline">Open the full record<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-external-link h-3.5 w-3.5" aria-hidden="true"><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path></svg></a>
<button type="button" class="rounded-lg border border-border 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">Back to the list</button>
</footer>
</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.
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.
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.