Skip to content
Product block

Record Created State

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.

260 lineslucide-reactAdded 13 Sept 2026
  • crud
  • success
  • confirmation
  • undo
  • state

What's included

  • components/crud-success-state.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add crud-success-state

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Purchase order raised

ReferencePO-2291
Supplier
Northwind Trading Ltd
Value
£41,200.00
Delivery
Rotterdam DC · 24 Sep 2026
Approver
Rhea Patel (auto-assigned)

You can withdraw this for another 20s. After that it is dispatched and reversing it is a credit note.

Back to purchase orders

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/crud-success-state.tsx
'use client'

/**
 * <CrudSuccessState> — the screen after the record is written.
 *
 * The twelfth CRUD shape and the one that is almost never designed. Most
 * products fire a toast and drop the user back on the list, which answers
 * "did it work" and nothing else. The three questions people actually have
 * after creating something are:
 *
 *   1. What exactly did I create? (the reference, copyable)
 *   2. What do I do next? (and the answer is rarely "look at a list")
 *   3. Can I take it back? (yes, for a few seconds, and then no)
 *
 * THE UNDO WINDOW IS A REAL COUNTDOWN, AND IT IS HONEST
 *
 * A countdown that keeps ticking after the window closes, or an Undo that
 * silently stops working, is worse than no undo. This one shows the seconds,
 * disables itself when they run out, and replaces the offer with the reason
 * it is gone — the record has been dispatched downstream and undo is now a
 * separate reversal, not a cancel.
 *
 * `setInterval` is cleared on unmount and the tick derives from a stored
 * deadline rather than from a counter, so a backgrounded tab that stops
 * firing timers does not come back with thirty seconds still on the clock.
 *
 * NEXT ACTIONS ARE RANKED, NOT LISTED. One primary, two secondary, one quiet
 * link back. Four equal-weight buttons is a menu, and a menu is what the user
 * came here to avoid.
 *
 * ACCESSIBILITY: the panel is `role="status"` so the outcome is announced
 * without stealing focus; the countdown is `aria-live="off"` and re-announced
 * only at the two thresholds that matter, because a per-second live region is
 * unusable with a screen reader. Focus moves to the heading on mount, which
 * is what makes this a page state rather than a toast.
 */

import * as React from 'react'
import { ArrowRight, Check, Copy, Plus, Send, Undo2 } from 'lucide-react'

export interface SuccessAction {
  id: string
  label: string
  description: string
  tone?: 'primary' | 'secondary'
}

export interface CrudSuccessStateProps {
  title?: string
  reference?: string
  summary?: { label: string; value: string }[]
  actions?: SuccessAction[]
  /** Seconds the undo offer stays live. */
  undoSeconds?: number
  className?: string
}

const DEFAULT_SUMMARY = [
  { label: 'Supplier', value: 'Northwind Trading Ltd' },
  { label: 'Value', value: '£41,200.00' },
  { label: 'Delivery', value: 'Rotterdam DC · 24 Sep 2026' },
  { label: 'Approver', value: 'Rhea Patel (auto-assigned)' },
]

const DEFAULT_ACTIONS: SuccessAction[] = [
  {
    id: 'send',
    label: 'Send to the supplier',
    description: 'Emails the order and starts the acknowledgement clock.',
    tone: 'primary',
  },
  {
    id: 'another',
    label: 'Raise another order',
    description: 'Same supplier and delivery site, empty line items.',
  },
  {
    id: 'attach',
    label: 'Attach a quote',
    description: 'Required before finance will match the invoice.',
  },
]

export function CrudSuccessState({
  title = 'Purchase order raised',
  reference = 'PO-2291',
  summary = DEFAULT_SUMMARY,
  actions = DEFAULT_ACTIONS,
  undoSeconds = 20,
  className = '',
}: CrudSuccessStateProps) {
  const headingRef = React.useRef<HTMLHeadingElement>(null)
  const [remaining, setRemaining] = React.useState(undoSeconds)
  const [undone, setUndone] = React.useState(false)
  const [copied, setCopied] = React.useState(false)

  // Focus the heading: what makes this a page state rather than a toast.
  React.useEffect(() => {
    headingRef.current?.focus()
  }, [])

  React.useEffect(() => {
    // Derived from a deadline, not a counter — a backgrounded tab whose
    // timers were throttled must not come back with the clock unspent.
    const deadline = Date.now() + undoSeconds * 1000
    const tick = window.setInterval(() => {
      const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
      setRemaining(left)
      if (left === 0) window.clearInterval(tick)
    }, 250)
    return () => window.clearInterval(tick)
  }, [undoSeconds])

  async function copyReference() {
    try {
      await navigator.clipboard?.writeText(reference)
      setCopied(true)
      window.setTimeout(() => setCopied(false), 1600)
    } catch {
      /* Clipboard is absent over plain http and in some webviews. */
    }
  }

  return (
    <section className={`bg-background px-4 py-14 sm:px-6 ${className}`}>
      <div
        role="status"
        className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-6 text-card-foreground"
      >
        <span
          aria-hidden
          className="grid h-11 w-11 place-items-center rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
        >
          <Check className="h-5 w-5" />
        </span>

        <h1
          ref={headingRef}
          tabIndex={-1}
          className="mt-4 text-xl font-bold tracking-tight outline-none"
        >
          {undone ? 'Order withdrawn' : title}
        </h1>

        {undone ? (
          <p className="mt-1.5 text-sm text-muted-foreground">
            {reference} has been withdrawn. Nothing was sent to the supplier.
          </p>
        ) : (
          <>
            <div className="mt-1.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
              <span>Reference</span>
              <span className="font-mono text-xs text-foreground">{reference}</span>
              <button
                type="button"
                onClick={copyReference}
                className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-xs font-medium transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                {copied ? (
                  <Check aria-hidden className="h-3 w-3 text-emerald-600" />
                ) : (
                  <Copy aria-hidden className="h-3 w-3" />
                )}
                {copied ? 'Copied' : 'Copy'}
              </button>
            </div>

            <dl className="mt-5 divide-y divide-border/70 rounded-xl border border-border">
              {summary.map((row) => (
                <div
                  key={row.label}
                  className="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm"
                >
                  <dt className="text-muted-foreground">{row.label}</dt>
                  <dd className="text-end font-medium">{row.value}</dd>
                </div>
              ))}
            </dl>

            {/* Ranked: one primary, the rest quieter. */}
            <ul className="mt-5 space-y-2">
              {actions.map((action) => (
                <li key={action.id}>
                  <button
                    type="button"
                    className={`flex w-full items-center justify-between gap-3 rounded-xl px-4 py-3 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
                      action.tone === 'primary'
                        ? 'bg-primary text-primary-foreground hover:opacity-90'
                        : 'border border-border hover:bg-muted'
                    }`}
                  >
                    <span className="min-w-0">
                      <span className="flex items-center gap-1.5 text-sm font-semibold">
                        {action.tone === 'primary' ? (
                          <Send aria-hidden className="h-4 w-4 rtl:rotate-180" />
                        ) : (
                          <Plus aria-hidden className="h-4 w-4" />
                        )}
                        {action.label}
                      </span>
                      <span
                        className={`mt-0.5 block text-xs ${
                          action.tone === 'primary'
                            ? 'text-primary-foreground/80'
                            : 'text-muted-foreground'
                        }`}
                      >
                        {action.description}
                      </span>
                    </span>
                    <ArrowRight
                      aria-hidden
                      className="h-4 w-4 shrink-0 opacity-70 rtl:rotate-180"
                    />
                  </button>
                </li>
              ))}
            </ul>

            {/* Honest: the offer disappears and says why. */}
            <div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-border p-3">
              {remaining > 0 ? (
                <>
                  <p aria-live="off" className="text-xs text-muted-foreground">
                    You can withdraw this for another{' '}
                    <span className="font-semibold text-foreground">{remaining}s</span>.
                    After that it is dispatched and reversing it is a credit note.
                  </p>
                  <button
                    type="button"
                    onClick={() => setUndone(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-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    <Undo2 aria-hidden className="h-3.5 w-3.5 rtl:rotate-180" />
                    Withdraw
                  </button>
                </>
              ) : (
                <p className="text-xs text-muted-foreground">
                  The withdrawal window has closed — the order is with the
                  supplier. Reversing it now raises a credit note instead.
                </p>
              )}
            </div>
          </>
        )}

        <p className="mt-5 text-center">
          <a
            href="#"
            className="text-sm font-medium text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
          >
            Back to purchase orders
          </a>
        </p>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/crud-success-state.tsx and import it where you need the section:

import { CrudSuccessState } from '@/components/crud-success-state'

Customize

3 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.

Props

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.

PropTypeDefault
titlestring'Purchase order raised'
referencestring'PO-2291'
summary{ label: string; value: string }[]DEFAULT_SUMMARY
actionsSuccessAction[]DEFAULT_ACTIONS
undoSecondsSeconds the undo offer stays live.number20
classNamestring''

Not using React?

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.

crud-success-state.html
<!--
  Record Created State — 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="bg-background px-4 py-14 sm:px-6 ">
  <div role="status" class="mx-auto max-w-xl rounded-2xl border border-border bg-card p-6 text-card-foreground">
    <span aria-hidden="true" class="grid h-11 w-11 place-items-center rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
      <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-5 w-5" aria-hidden="true">
        <path d="M20 6 9 17l-5-5"></path>
      </svg>
    </span>
    <h1 tabindex="-1" class="mt-4 text-xl font-bold tracking-tight outline-none">Purchase order raised</h1>
    <div class="mt-1.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
      <span>Reference</span>
      <span class="font-mono text-xs text-foreground">PO-2291</span>
      <button type="button" class="inline-flex items-center gap-1 rounded px-1 py-0.5 text-xs font-medium 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 w-3" 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>Copy</button>
    </div>
    <dl class="mt-5 divide-y divide-border/70 rounded-xl border border-border">
      <div class="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
        <dt class="text-muted-foreground">Supplier</dt>
        <dd class="text-end font-medium">Northwind Trading Ltd</dd>
      </div>
      <div class="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
        <dt class="text-muted-foreground">Value</dt>
        <dd class="text-end font-medium">£41,200.00</dd>
      </div>
      <div class="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
        <dt class="text-muted-foreground">Delivery</dt>
        <dd class="text-end font-medium">Rotterdam DC · 24 Sep 2026</dd>
      </div>
      <div class="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
        <dt class="text-muted-foreground">Approver</dt>
        <dd class="text-end font-medium">Rhea Patel (auto-assigned)</dd>
      </div>
    </dl>
    <ul class="mt-5 space-y-2">
      <li>
        <button type="button" class="flex w-full items-center justify-between gap-3 rounded-xl px-4 py-3 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring bg-primary text-primary-foreground hover:opacity-90">
          <span class="min-w-0">
            <span class="flex items-center gap-1.5 text-sm font-semibold"><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-send h-4 w-4 rtl:rotate-180" aria-hidden="true"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg>Send to the supplier</span>
            <span class="mt-0.5 block text-xs text-primary-foreground/80">Emails the order and starts the acknowledgement clock.</span>
          </span>
          <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-arrow-right h-4 w-4 shrink-0 opacity-70 rtl:rotate-180" aria-hidden="true">
            <path d="M5 12h14"></path>
            <path d="m12 5 7 7-7 7"></path>
          </svg>
        </button>
      </li>
      <li>
        <button type="button" class="flex w-full items-center justify-between gap-3 rounded-xl px-4 py-3 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border border-border hover:bg-muted">
          <span class="min-w-0">
            <span class="flex items-center gap-1.5 text-sm font-semibold"><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>Raise another order</span>
            <span class="mt-0.5 block text-xs text-muted-foreground">Same supplier and delivery site, empty line items.</span>
          </span>
          <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-arrow-right h-4 w-4 shrink-0 opacity-70 rtl:rotate-180" aria-hidden="true">
            <path d="M5 12h14"></path>
            <path d="m12 5 7 7-7 7"></path>
          </svg>
        </button>
      </li>
      <li>
        <button type="button" class="flex w-full items-center justify-between gap-3 rounded-xl px-4 py-3 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border border-border hover:bg-muted">
          <span class="min-w-0">
            <span class="flex items-center gap-1.5 text-sm font-semibold"><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>Attach a quote</span>
            <span class="mt-0.5 block text-xs text-muted-foreground">Required before finance will match the invoice.</span>
          </span>
          <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-arrow-right h-4 w-4 shrink-0 opacity-70 rtl:rotate-180" aria-hidden="true">
            <path d="M5 12h14"></path>
            <path d="m12 5 7 7-7 7"></path>
          </svg>
        </button>
      </li>
    </ul>
    <div class="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-border p-3">
      <p aria-live="off" class="text-xs text-muted-foreground">You can withdraw this for another <span class="font-semibold text-foreground">20s</span>. After that it is dispatched and reversing it is a credit note.</p>
      <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-semibold 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-undo2 lucide-undo-2 h-3.5 w-3.5 rtl:rotate-180" aria-hidden="true"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"></path></svg>Withdraw</button>
    </div>
    <p class="mt-5 text-center">
      <a href="#" class="text-sm font-medium text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline">Back to purchase orders</a>
    </p>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

11 more blocks in CRUD

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 CRUD

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More CRUD blocks

View category
Open the full page for this block

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.

CRUD408 lines1 dep
Open the full page for this block

Create Record Modal

Three fields over the list they add to, with the new row appearing behind the dialog rather than after it closes.

CRUD262 lines1 dep
Open the full page for this block

Create Record Drawer

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.

CRUD336 lines1 dep
Open the full page for this block

Record Detail Page

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.

CRUD314 lines1 dep
Open the full page for this block

Record Quick Look

The verification modal from a table row: copyable identifiers, no editing, nothing scrolling, and the row still marked behind it.

CRUD244 lines1 dep
Open the full page for this block

Edit Record Form

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.

CRUD259 lines1 dep
Open the full page for this block

Edit Record Modal

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.

CRUD248 lines1 dep
Open the full page for this block

Field-Level Edit Drawer

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.

CRUD327 lines1 dep
Open the full page for this block

Cascade Delete Dialog

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.

CRUD262 lines1 dep
Open the full page for this block

Bulk Edit Drawer

One change applied to many records that disagree - mixed values shown as mixed, untouched fields never written, and skipped rows counted before you commit.

CRUD229 lines1 dep
Open the full page for this block

Archive and Restore

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.

CRUD277 lines1 dep