Slide-Over Cart Drawer
A basket panel with the dialog mechanics usually missing: focus trap, focus return, Escape to close and a background scroll lock.
The only checkout where the buyer is not the recipient - live preview, a send date with a stated timezone, and the confirm-the-address field that prevents the expensive typo.
components/gift-card-purchase.tsxnpx hoverlab add gift-card-purchase
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'
/**
* <GiftCardPurchase> — buying a card for someone else.
*
* The only checkout flow where the buyer is not the recipient, which breaks
* three assumptions the rest of the cart is built on: the delivery address is
* an email address, the delivery date is chosen rather than estimated, and the
* confirmation has to go to two different people saying two different things.
*
* WHAT THAT MEANS IN THE FORM
*
* - **A live preview.** The buyer is designing something a person they care
* about will see. A preview that updates as they type is not decoration; it
* is the only way to check the message reads right and the name is spelled
* correctly, and it converts better than any amount of reassurance copy.
* - **A send date, with a timezone stated.** "Deliver on 25 December" means
* nothing without saying whose midnight. Most gift-card flows omit it and
* then send Christmas presents on Christmas Eve.
* - **A typo guard on the recipient address.** The single most expensive
* error in this flow: money leaves, the card goes to nobody, and recovery
* is a support case. Confirming the address is worth one extra field.
*
* AMOUNTS ARE PRESETS PLUS A FREE FIELD, NOT A DROPDOWN. Four common values as
* buttons and a custom input under them, with the min and max stated before
* the error rather than after it.
*
* NOTHING HERE IS A PHYSICAL PRODUCT, so there is no shipping step, no address
* form and no delivery estimate. Reusing the standard checkout and disabling
* three sections is the common shortcut and it makes the flow look broken.
*
* ACCESSIBILITY: the preview is `aria-hidden` and duplicated as a visually
* hidden summary, because a live-updating card is noise to a screen reader
* while a summary read on demand is useful; the amount presets are a radio
* group; the confirm-email mismatch is announced through the field's own
* `aria-describedby`, not through a form-level banner.
*/
import * as React from 'react'
import { Gift, Mail, Sparkles } from 'lucide-react'
export interface GiftDesign {
id: string
label: string
/** Tailwind classes for the preview face. Tokens only, no hard-coded hex. */
face: string
}
export interface GiftCardPurchaseProps {
amounts?: number[]
min?: number
max?: number
designs?: GiftDesign[]
currency?: string
className?: string
}
const DEFAULT_DESIGNS: GiftDesign[] = [
{ id: 'plain', label: 'Plain', face: 'bg-card text-card-foreground border border-border' },
{ id: 'brand', label: 'Brand', face: 'bg-primary text-primary-foreground' },
{ id: 'dark', label: 'Midnight', face: 'bg-foreground text-background' },
{ id: 'warm', label: 'Warm', face: 'bg-amber-500/15 text-foreground border border-amber-500/40' },
]
const INPUT_CLASS =
'w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring'
export function GiftCardPurchase({
amounts = [25, 50, 100, 250],
min = 10,
max = 500,
designs = DEFAULT_DESIGNS,
currency = '£',
className = '',
}: GiftCardPurchaseProps) {
const uid = React.useId()
const [amount, setAmount] = React.useState(50)
const [custom, setCustom] = React.useState('')
const [designId, setDesignId] = React.useState(designs[1]?.id ?? '')
const [to, setTo] = React.useState('imani@example.com')
const [confirmTo, setConfirmTo] = React.useState('imani@example.com')
const [name, setName] = React.useState('Imani')
const [from, setFrom] = React.useState('Marte')
const [message, setMessage] = React.useState(
'For the desk you keep talking about. Happy birthday.',
)
const [sendOn, setSendOn] = React.useState('2026-09-24')
const design = designs.find((d) => d.id === designId) ?? designs[0]
const customValue = Number.parseInt(custom, 10)
const usingCustom = custom.trim().length > 0
const value = usingCustom && !Number.isNaN(customValue) ? customValue : amount
const customInvalid = usingCustom && (Number.isNaN(customValue) || customValue < min || customValue > max)
const mismatch = confirmTo.length > 0 && confirmTo !== to
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<div className="mx-auto grid max-w-4xl gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
<form onSubmit={(e) => e.preventDefault()}>
<h2 className="flex items-center gap-2 text-xl font-bold tracking-tight">
<Gift aria-hidden className="h-5 w-5 text-primary" />
Send a gift card
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Delivered by email on a date you choose. Nothing is posted, so there
is no shipping to pay and nothing to arrive late.
</p>
<fieldset className="mt-6 border-0 p-0">
<legend className="text-sm font-semibold">Amount</legend>
<div className="mt-2 flex flex-wrap gap-2">
{amounts.map((preset) => {
const id = `${uid}-a-${preset}`
const on = !usingCustom && amount === preset
return (
<div key={preset}>
<input
type="radio"
id={id}
name={`${uid}-amount`}
checked={on}
onChange={() => {
setAmount(preset)
setCustom('')
}}
className="peer sr-only"
/>
<label
htmlFor={id}
className={`block cursor-pointer rounded-lg border px-4 py-2 text-sm font-semibold transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring ${
on ? 'border-primary bg-primary/10 text-primary' : 'border-border hover:bg-muted'
}`}
>
{currency}
{preset}
</label>
</div>
)
})}
</div>
<div className="mt-2">
<label htmlFor={`${uid}-custom`} className="block text-xs font-medium">
Or another amount
</label>
<input
id={`${uid}-custom`}
inputMode="numeric"
value={custom}
onChange={(e) => setCustom(e.target.value.replace(/\D/g, ''))}
aria-invalid={customInvalid || undefined}
aria-describedby={`${uid}-custom-range`}
placeholder={`${min}–${max}`}
className={`mt-1 w-32 ${INPUT_CLASS} ${customInvalid ? 'border-destructive' : ''}`}
/>
{/* Stated before the error, not after it. */}
<p id={`${uid}-custom-range`} className="mt-1 text-xs text-muted-foreground">
Between {currency}
{min} and {currency}
{max}.
</p>
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="text-sm font-semibold">Design</legend>
<div className="mt-2 flex flex-wrap gap-2">
{designs.map((option) => {
const id = `${uid}-d-${option.id}`
const on = designId === option.id
return (
<div key={option.id}>
<input
type="radio"
id={id}
name={`${uid}-design`}
checked={on}
onChange={() => setDesignId(option.id)}
className="peer sr-only"
/>
<label
htmlFor={id}
className={`block cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring ${
on ? 'border-primary bg-primary/10 text-primary' : 'border-border hover:bg-muted'
}`}
>
{option.label}
</label>
</div>
)
})}
</div>
</fieldset>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor={`${uid}-to`} className="block text-sm font-medium">
Their email
</label>
<input
id={`${uid}-to`}
type="email"
required
value={to}
onChange={(e) => setTo(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
<div>
<label htmlFor={`${uid}-to2`} className="block text-sm font-medium">
Their email again
</label>
<input
id={`${uid}-to2`}
type="email"
required
value={confirmTo}
onChange={(e) => setConfirmTo(e.target.value)}
aria-invalid={mismatch || undefined}
aria-describedby={`${uid}-to2-note`}
className={`mt-1.5 ${INPUT_CLASS} ${mismatch ? 'border-destructive' : ''}`}
/>
{/* On the field, not in a form-level banner. */}
<p
id={`${uid}-to2-note`}
className={`mt-1 text-xs ${mismatch ? 'text-destructive' : 'text-muted-foreground'}`}
>
{mismatch
? 'These do not match. A card sent to the wrong address cannot be recalled.'
: 'Asked twice because a mistyped address means the money is gone.'}
</p>
</div>
<div>
<label htmlFor={`${uid}-name`} className="block text-sm font-medium">
Their name
</label>
<input
id={`${uid}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
<div>
<label htmlFor={`${uid}-from`} className="block text-sm font-medium">
From
</label>
<input
id={`${uid}-from`}
value={from}
onChange={(e) => setFrom(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
<div className="sm:col-span-2">
<label htmlFor={`${uid}-message`} className="block text-sm font-medium">
Message
<span className="ms-2 text-xs font-normal text-muted-foreground">
{message.length}/200
</span>
</label>
<textarea
id={`${uid}-message`}
rows={3}
maxLength={200}
value={message}
onChange={(e) => setMessage(e.target.value)}
className={`mt-1.5 ${INPUT_CLASS}`}
/>
</div>
<div className="sm:col-span-2">
<label htmlFor={`${uid}-when`} className="block text-sm font-medium">
Send on
</label>
<input
id={`${uid}-when`}
type="date"
min="2026-09-14"
value={sendOn}
onChange={(e) => setSendOn(e.target.value)}
aria-describedby={`${uid}-when-note`}
className={`mt-1.5 w-48 ${INPUT_CLASS}`}
/>
{/* Whose midnight — the omission that sends presents a day early. */}
<p id={`${uid}-when-note`} className="mt-1 text-xs text-muted-foreground">
Sent at 09:00 in the recipient’s timezone, not yours.
</p>
</div>
</div>
</form>
<aside className="lg:sticky lg:top-6 lg:self-start">
<h3 data-stress-ignore className="text-sm font-semibold">
Preview
</h3>
{/* Decorative: the same content is in the summary below. */}
<div
aria-hidden
className={`mt-2 rounded-2xl p-5 ${design?.face ?? ''}`}
>
<p className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide opacity-80">
<Sparkles className="h-3.5 w-3.5" />
Gift card
</p>
<p className="mt-3 text-3xl font-bold tracking-tight">
{currency}
{Number.isNaN(value) ? 0 : value}
</p>
<p className="mt-3 text-sm">
For {name || 'them'}
{message ? ` — ${message}` : ''}
</p>
<p className="mt-3 text-xs opacity-80">From {from || 'you'}</p>
</div>
<p className="sr-only">
Preview: a {currency}
{Number.isNaN(value) ? 0 : value} gift card for {name || 'the recipient'},
from {from || 'you'}, reading: {message}. Sent to {to} on {sendOn}.
</p>
<div className="mt-4 rounded-xl border border-border bg-card p-4">
<dl className="space-y-2 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Card value</dt>
<dd className="font-medium">
{currency}
{Number.isNaN(value) ? 0 : value}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Delivery</dt>
<dd className="min-w-0 truncate">Free · by email</dd>
</div>
<div className="flex justify-between gap-3 border-t border-border pt-2 font-semibold">
<dt>To pay</dt>
<dd>
{currency}
{Number.isNaN(value) ? 0 : value}
</dd>
</div>
</dl>
<button
type="button"
disabled={mismatch || customInvalid}
className="mt-4 inline-flex w-full 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"
>
<Mail aria-hidden className="h-4 w-4" />
Buy and schedule
</button>
<p className="mt-2 text-xs text-muted-foreground">
You get a receipt straight away. {name || 'They'} get the card on{' '}
{sendOn} — two different emails, saying two different things.
</p>
</div>
</aside>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/gift-card-purchase.tsx and import it where you need the section:
import { GiftCardPurchase } from '@/components/gift-card-purchase'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.
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 |
|---|---|---|
amounts | number[] | [25, 50, 100, 250] |
min | number | 10 |
max | number | 500 |
designs | GiftDesign[] | DEFAULT_DESIGNS |
currency | string | '£' |
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.
<!--
Gift Card Purchase — 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-10 sm:px-6 ">
<div class="mx-auto grid max-w-4xl gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
<form>
<h2 class="flex items-center gap-2 text-xl font-bold tracking-tight"><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-gift h-5 w-5 text-primary" aria-hidden="true"><rect x="3" y="8" width="18" height="4" rx="1"></rect><path d="M12 8v13"></path><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"></path><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"></path></svg>Send a gift card</h2>
<p class="mt-1 text-sm text-muted-foreground">Delivered by email on a date you choose. Nothing is posted, so there is no shipping to pay and nothing to arrive late.</p>
<fieldset class="mt-6 border-0 p-0">
<legend class="text-sm font-semibold">Amount</legend>
<div class="mt-2 flex flex-wrap gap-2">
<div>
<input type="radio" id="_R_0_-a-25" class="peer sr-only" name="_R_0_-amount" />
<label for="_R_0_-a-25" class="block cursor-pointer rounded-lg border px-4 py-2 text-sm font-semibold transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted">£25</label>
</div>
<div>
<input type="radio" id="_R_0_-a-50" class="peer sr-only" name="_R_0_-amount" checked="" />
<label for="_R_0_-a-50" class="block cursor-pointer rounded-lg border px-4 py-2 text-sm font-semibold transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-primary bg-primary/10 text-primary">£50</label>
</div>
<div>
<input type="radio" id="_R_0_-a-100" class="peer sr-only" name="_R_0_-amount" />
<label for="_R_0_-a-100" class="block cursor-pointer rounded-lg border px-4 py-2 text-sm font-semibold transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted">£100</label>
</div>
<div>
<input type="radio" id="_R_0_-a-250" class="peer sr-only" name="_R_0_-amount" />
<label for="_R_0_-a-250" class="block cursor-pointer rounded-lg border px-4 py-2 text-sm font-semibold transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted">£250</label>
</div>
</div>
<div class="mt-2">
<label for="_R_0_-custom" class="block text-xs font-medium">Or another amount</label>
<input id="_R_0_-custom" inputMode="numeric" aria-describedby="_R_0_-custom-range" placeholder="10–500" class="mt-1 w-32 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring " value="" />
<p id="_R_0_-custom-range" class="mt-1 text-xs text-muted-foreground">Between £10 and £500.</p>
</div>
</fieldset>
<fieldset class="mt-6 border-0 p-0">
<legend class="text-sm font-semibold">Design</legend>
<div class="mt-2 flex flex-wrap gap-2">
<div>
<input type="radio" id="_R_0_-d-plain" class="peer sr-only" name="_R_0_-design" />
<label for="_R_0_-d-plain" class="block cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted">Plain</label>
</div>
<div>
<input type="radio" id="_R_0_-d-brand" class="peer sr-only" name="_R_0_-design" checked="" />
<label for="_R_0_-d-brand" class="block cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-primary bg-primary/10 text-primary">Brand</label>
</div>
<div>
<input type="radio" id="_R_0_-d-dark" class="peer sr-only" name="_R_0_-design" />
<label for="_R_0_-d-dark" class="block cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted">Midnight</label>
</div>
<div>
<input type="radio" id="_R_0_-d-warm" class="peer sr-only" name="_R_0_-design" />
<label for="_R_0_-d-warm" class="block cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted">Warm</label>
</div>
</div>
</fieldset>
<div class="mt-6 grid gap-4 sm:grid-cols-2">
<div>
<label for="_R_0_-to" class="block text-sm font-medium">Their email</label>
<input id="_R_0_-to" type="email" required="" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="imani@example.com" />
</div>
<div>
<label for="_R_0_-to2" class="block text-sm font-medium">Their email again</label>
<input id="_R_0_-to2" type="email" required="" aria-describedby="_R_0_-to2-note" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring " value="imani@example.com" />
<p id="_R_0_-to2-note" class="mt-1 text-xs text-muted-foreground">Asked twice because a mistyped address means the money is gone.</p>
</div>
<div>
<label for="_R_0_-name" class="block text-sm font-medium">Their name</label>
<input id="_R_0_-name" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="Imani" />
</div>
<div>
<label for="_R_0_-from" class="block text-sm font-medium">From</label>
<input id="_R_0_-from" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="Marte" />
</div>
<div class="sm:col-span-2">
<label for="_R_0_-message" class="block text-sm font-medium">Message<span class="ms-2 text-xs font-normal text-muted-foreground">52/200</span></label>
<textarea id="_R_0_-message" rows="3" maxLength="200" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">For the desk you keep talking about. Happy birthday.</textarea>
</div>
<div class="sm:col-span-2">
<label for="_R_0_-when" class="block text-sm font-medium">Send on</label>
<input id="_R_0_-when" type="date" min="2026-09-14" aria-describedby="_R_0_-when-note" class="mt-1.5 w-48 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="2026-09-24" />
<p id="_R_0_-when-note" class="mt-1 text-xs text-muted-foreground">Sent at 09:00 in the recipient’s timezone, not yours.</p>
</div>
</div>
</form>
<aside class="lg:sticky lg:top-6 lg:self-start">
<h3 data-stress-ignore="true" class="text-sm font-semibold">Preview</h3>
<div aria-hidden="true" class="mt-2 rounded-2xl p-5 bg-primary text-primary-foreground">
<p class="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide opacity-80"><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-sparkles h-3.5 w-3.5" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"></path><path d="M20 3v4"></path><path d="M22 5h-4"></path><path d="M4 17v2"></path><path d="M5 18H3"></path></svg>Gift card</p>
<p class="mt-3 text-3xl font-bold tracking-tight">£50</p>
<p class="mt-3 text-sm">For Imani — For the desk you keep talking about. Happy birthday.</p>
<p class="mt-3 text-xs opacity-80">From Marte</p>
</div>
<p class="sr-only">Preview: a £50 gift card for Imani, from Marte, reading: For the desk you keep talking about. Happy birthday.. Sent to imani@example.com on 2026-09-24.</p>
<div class="mt-4 rounded-xl border border-border bg-card p-4">
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-3">
<dt class="text-muted-foreground">Card value</dt>
<dd class="font-medium">£50</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted-foreground">Delivery</dt>
<dd class="min-w-0 truncate">Free · by email</dd>
</div>
<div class="flex justify-between gap-3 border-t border-border pt-2 font-semibold">
<dt>To pay</dt>
<dd>£50</dd>
</div>
</dl>
<button type="button" class="mt-4 inline-flex w-full 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-mail h-4 w-4" aria-hidden="true"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path><rect x="2" y="4" width="20" height="16" rx="2"></rect></svg>Buy and schedule</button>
<p class="mt-2 text-xs text-muted-foreground">You get a receipt straight away. Imani get the card on 2026-09-24 — two different emails, saying two different things.</p>
</div>
</aside>
</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 Cart & CheckoutCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A basket panel with the dialog mechanics usually missing: focus trap, focus return, Escape to close and a background scroll lock.
Quantity steppers and remove-with-undo, with every total derived rather than stored so the basket can never disagree with itself.
The full autocomplete token set that lets a browser fill eight fields in one tap, plus address labels that follow the selected country.
Subtotal, discount, delivery and tax all derived from the lines, with the free-delivery nudge driven by the same threshold as the charge.
The wallet buttons above the form, and what they do to it: the address a wallet returns is shown and stays editable, and the row disappears entirely when no wallet is actually available.
The gifting step, placed in the cart where it is a choice rather than at checkout where it is friction.
Guest as the default and the whole form, with a real autocomplete token on every field and the account offer moved to the end, where it costs one checkbox.
The offer popup with four rules applied: triggered by intent, a decline button of equal weight, a dismissal that is remembered, and the terms on its face.