Skip to content
Commerce block

Gift Card Purchase

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.

361 lineslucide-reactAdded 13 Sept 2026
  • gift card
  • checkout
  • commerce
  • purchase
  • email

What's included

  • components/gift-card-purchase.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add gift-card-purchase

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

Send a gift card

Delivered by email on a date you choose. Nothing is posted, so there is no shipping to pay and nothing to arrive late.

Amount

Between £10 and £500.

Design

Asked twice because a mistyped address means the money is gone.

Sent at 09:00 in the recipient’s timezone, not yours.

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

Source

components/gift-card-purchase.tsx
'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&rsquo;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>
  )
}

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/gift-card-purchase.tsx and import it where you need the section:

import { GiftCardPurchase } from '@/components/gift-card-purchase'

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
amountsnumber[][25, 50, 100, 250]
minnumber10
maxnumber500
designsGiftDesign[]DEFAULT_DESIGNS
currencystring'£'
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.

gift-card-purchase.html
<!--
  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>
  • 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

8 more blocks in Cart & Checkout

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 & Checkout

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 Cart & Checkout blocks

View category
Open the full page for this block

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.

Cart & Checkout256 lines1 dep
Open the full page for this block

Editable Cart Lines

Quantity steppers and remove-with-undo, with every total derived rather than stored so the basket can never disagree with itself.

Cart & Checkout199 lines1 dep
Open the full page for this block

Checkout Address & Payment

The full autocomplete token set that lets a browser fill eight fields in one tap, plus address labels that follow the selected country.

Cart & Checkout259 lines1 dep
Open the full page for this block

Order Summary With Promo

Subtotal, discount, delivery and tax all derived from the lines, with the free-delivery nudge driven by the same threshold as the charge.

Cart & Checkout228 lines1 dep
Open the full page for this block

Express Payment Row

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.

Cart & Checkout258 lines1 dep
Open the full page for this block

Gift Options Form

The gifting step, placed in the cart where it is a choice rather than at checkout where it is friction.

Cart & Checkout177 linesNo deps
Open the full page for this block

Guest Checkout

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.

Cart & Checkout396 lines1 dep
Open the full page for this block

Discount Offer Modal

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.

Cart & Checkout214 lines1 dep