Gift cards
Gift cards to design and send, ready cards by occasion, a balance check and the code field in the cart. The card motifs are drawn, your own artwork goes in their place.
One of 4 components in Account and loyalty
Variants
Details
What Gift cards brings along, read from the registry and the source.
- Category
- Account and loyalty
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/gift-cards.tsx
- npm packages
- None
- Languages
- English and German, 43 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The GiftCardsProps interface from components/ui/gift-cards.tsx, with the comments from the code.
variantoptionalGiftCardsVariantdesigneramountsbalanceredeem
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringbrandoptionalstringThe shop name printed on the card
designsoptionalGiftDesign[]amountsoptionalnumber[]rangeoptional[number, number]designer: lowest and highest free amount
offersoptionalGiftOffer[]balanceoptional{ code: string; amount: number; initial: number; expires?: string; history?: { date: string; text: string; amount: number }[] }balance: what the demo code shows
defaultCheckedoptionalbooleanbalance: show the result right away (previews)
cartoptional{ subtotal: number; shipping?: number; validCode: string; codeValue: number }redeem: the cart total and a code that works
defaultCodeoptionalstringredeem: a code already in the field, e.g. from a link
currencyoptionalstringdefaultValuesoptional{ to?: string; from?: string; message?: string; amount?: number; designId?: string }designer: start values, e.g. from a "Gift this" button
onBuyoptional(card: { designId: string; amount: number; to: string; from: string; message: string; date: string }) => void
Built-in texts
Gift cards writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 43 texts
| Key | English | German |
|---|---|---|
| giftCard | English Gift card | German Gutschein |
| design | English Design | German Motiv |
| amount | English Amount | German Betrag |
| other | English Other amount | German Anderer Betrag |
| between | English Any amount from | German Jeder Betrag von |
| and | English to | German bis |
| fillIn | English Fill in name, email and your name so the card can go out. | German Trag Name, E-Mail und deinen Namen ein, damit die Karte rausgehen kann. |
| to | English For | German Für |
| toPlaceholder | English Their name | German Name der beschenkten Person |
| toEmail | English Their email | German E-Mail der beschenkten Person |
| from | English From | German Von |
| fromPlaceholder | English Your name | German Dein Name |
| message | English Message | German Nachricht |
| messagePlaceholder | English A few words for the card | German Ein paar Worte für die Karte |
| sendOn | English Send on | German Senden am |
| today | English Right away | German Sofort |
| later | English On a date | German An einem Datum |
| buy | English Add gift card to cart | German Gutschein in den Warenkorb |
| added | English Gift card in your cart | German Gutschein im Warenkorb |
| validFor | English Valid for three years, for everything in the shop and in our stores. | German Drei Jahre gültig, für alles im Shop und in unseren Läden. |
| choose | English Add to cart | German In den Warenkorb |
| chosen | English In your cart | German Im Warenkorb |
| code | English Gift card code | German Gutscheincode |
| codePlaceholder | English XXXX XXXX XXXX | German XXXX XXXX XXXX |
| check | English Check balance | German Guthaben prüfen |
| checking | English Checking | German Wird geprüft |
| left | English left | German übrig |
| of | English of | German von |
| expires | English Valid until | German Gültig bis |
| spent | English Spent | German Eingelöst |
| checkAnother | English Check another card | German Anderen Gutschein prüfen |
| notFound | English This code is not known. It has 12 characters and stands under the scratch field. | German Diesen Code kennen wir nicht. Er hat 12 Zeichen und steht unter dem Rubbelfeld. |
| haveCard | English Have a gift card? | German Hast du einen Gutschein? |
| apply | English Apply | German Einlösen |
| applied | English Gift card applied | German Gutschein eingelöst |
| remove | English Remove gift card | German Gutschein entfernen |
| wrongCode | English This code does not work. Check the spelling, it has 12 characters. | German Dieser Code passt nicht. Prüf die Schreibweise, er hat 12 Zeichen. |
| subtotal | English Subtotal | German Zwischensumme |
| shipping | English Shipping | German Versand |
| free | English Free | German Kostenlos |
| total | English Total | German Gesamt |
| stillOnCard | English stays on your card | German bleiben auf deinem Gutschein |
| pay | English Continue to payment | German Weiter zur Zahlung |
Accessibility
Read from the source of the component.
- Sets aria-describedby, aria-expanded, aria-invalid, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles alert and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 781 lines
'use client';
import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { ShopMotion, Swap, TweenPrice, formatPrice } from '@/components/ui/product-image';
/*
* Gift cards: design and send one, buy a ready one, check a balance and
* redeem a code at checkout.
*
* designer pick a design, an amount, a message and a date, the card writes along
* amounts ready cards by amount and occasion as a row of cards that tilt toward the pointer
* balance enter the code, see what is left and where it was spent
* redeem the field in the cart: apply a code, the total counts down
*
* The card motifs are drawn. Pass `designs` with your own artwork to replace them.
*/
export type GiftCardsVariant = 'designer' | 'amounts' | 'balance' | 'redeem';
export type GiftMotif = 'waves' | 'sun' | 'leaves' | 'confetti' | 'stars' | 'plain';
export interface GiftDesign {
id: string;
name: string;
motif?: GiftMotif;
/** Two colors for the card, any CSS color */
from: string;
to: string;
/** Text on the card is dark (light designs) */
dark?: boolean;
/** Your own artwork instead of the drawn motif */
art?: React.ReactNode;
}
export interface GiftOffer {
id: string;
amount: number;
/** e.g. "Birthday" */
occasion?: string;
designId?: string;
note?: string;
href?: string;
}
export interface GiftCardsProps {
variant?: GiftCardsVariant;
eyebrow?: string;
title?: string;
description?: string;
/** The shop name printed on the card */
brand?: string;
designs?: GiftDesign[];
amounts?: number[];
/** designer: lowest and highest free amount */
range?: [number, number];
offers?: GiftOffer[];
/** balance: what the demo code shows */
balance?: { code: string; amount: number; initial: number; expires?: string; history?: { date: string; text: string; amount: number }[] };
/** balance: show the result right away (previews) */
defaultChecked?: boolean;
/** redeem: the cart total and a code that works */
cart?: { subtotal: number; shipping?: number; validCode: string; codeValue: number };
/** redeem: a code already in the field, e.g. from a link */
defaultCode?: string;
currency?: string;
/** designer: start values, e.g. from a "Gift this" button */
defaultValues?: { to?: string; from?: string; message?: string; amount?: number; designId?: string };
onBuy?: (card: { designId: string; amount: number; to: string; from: string; message: string; date: string }) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
giftCard: 'Gift card',
design: 'Design',
amount: 'Amount',
other: 'Other amount',
between: 'Any amount from',
and: 'to',
fillIn: 'Fill in name, email and your name so the card can go out.',
to: 'For',
toPlaceholder: 'Their name',
toEmail: 'Their email',
from: 'From',
fromPlaceholder: 'Your name',
message: 'Message',
messagePlaceholder: 'A few words for the card',
sendOn: 'Send on',
today: 'Right away',
later: 'On a date',
buy: 'Add gift card to cart',
added: 'Gift card in your cart',
validFor: 'Valid for three years, for everything in the shop and in our stores.',
choose: 'Add to cart',
chosen: 'In your cart',
code: 'Gift card code',
codePlaceholder: 'XXXX XXXX XXXX',
check: 'Check balance',
checking: 'Checking',
left: 'left',
of: 'of',
expires: 'Valid until',
spent: 'Spent',
checkAnother: 'Check another card',
notFound: 'This code is not known. It has 12 characters and stands under the scratch field.',
haveCard: 'Have a gift card?',
apply: 'Apply',
applied: 'Gift card applied',
remove: 'Remove gift card',
wrongCode: 'This code does not work. Check the spelling, it has 12 characters.',
subtotal: 'Subtotal',
shipping: 'Shipping',
free: 'Free',
total: 'Total',
stillOnCard: 'stays on your card',
pay: 'Continue to payment',
},
de: {
giftCard: 'Gutschein',
design: 'Motiv',
amount: 'Betrag',
other: 'Anderer Betrag',
between: 'Jeder Betrag von',
and: 'bis',
fillIn: 'Trag Name, E-Mail und deinen Namen ein, damit die Karte rausgehen kann.',
to: 'Für',
toPlaceholder: 'Name der beschenkten Person',
toEmail: 'E-Mail der beschenkten Person',
from: 'Von',
fromPlaceholder: 'Dein Name',
message: 'Nachricht',
messagePlaceholder: 'Ein paar Worte für die Karte',
sendOn: 'Senden am',
today: 'Sofort',
later: 'An einem Datum',
buy: 'Gutschein in den Warenkorb',
added: 'Gutschein im Warenkorb',
validFor: 'Drei Jahre gültig, für alles im Shop und in unseren Läden.',
choose: 'In den Warenkorb',
chosen: 'Im Warenkorb',
code: 'Gutscheincode',
codePlaceholder: 'XXXX XXXX XXXX',
check: 'Guthaben prüfen',
checking: 'Wird geprüft',
left: 'übrig',
of: 'von',
expires: 'Gültig bis',
spent: 'Eingelöst',
checkAnother: 'Anderen Gutschein prüfen',
notFound: 'Diesen Code kennen wir nicht. Er hat 12 Zeichen und steht unter dem Rubbelfeld.',
haveCard: 'Hast du einen Gutschein?',
apply: 'Einlösen',
applied: 'Gutschein eingelöst',
remove: 'Gutschein entfernen',
wrongCode: 'Dieser Code passt nicht. Prüf die Schreibweise, er hat 12 Zeichen.',
subtotal: 'Zwischensumme',
shipping: 'Versand',
free: 'Kostenlos',
total: 'Gesamt',
stillOnCard: 'bleiben auf deinem Gutschein',
pay: 'Weiter zur Zahlung',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const FIELD = 'h-11 w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]';
function Head({ eyebrow, title, description, center }: { eyebrow?: string; title?: string; description?: string; center?: boolean }) {
if (!title) return null;
return (
<div className={cn('mb-10 max-w-[40rem] sm:mb-12', center && 'mx-auto text-center')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* The drawn card */
/* ------------------------------------------------------------------ */
function Motif({ motif = 'plain' }: { motif?: GiftMotif }) {
const stroke = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, vectorEffect: 'non-scaling-stroke' as const };
return (
<svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 size-full opacity-30" aria-hidden data-clip-ok>
{motif === 'waves' && [0, 1, 2, 3, 4, 5].map((i) => <path key={i} d={`M-10 ${70 + i * 22} C 60 ${50 + i * 22}, 120 ${95 + i * 22}, 190 ${70 + i * 22} S 300 ${50 + i * 22}, 340 ${72 + i * 22}`} {...stroke} />)}
{motif === 'sun' && (
<g>
<circle cx="250" cy="70" r="42" fill="currentColor" opacity="0.5" />
{Array.from({ length: 12 }, (_, i) => {
const a = (i / 12) * Math.PI * 2;
return <path key={i} d={`M${round(250 + Math.cos(a) * 56)} ${round(70 + Math.sin(a) * 56)} L${round(250 + Math.cos(a) * 74)} ${round(70 + Math.sin(a) * 74)}`} {...stroke} />;
})}
<path d="M-10 170 C 80 140, 200 190, 340 150" {...stroke} />
</g>
)}
{motif === 'leaves' &&
[
[60, 160, -30],
[130, 40, 20],
[250, 140, -10],
[290, 30, 40],
[20, 50, 60],
[190, 190, 0],
].map(([x, y, r], i) => (
<g key={i} transform={`translate(${x} ${y}) rotate(${r})`}>
<path d="M0 -34 C 22 -18, 22 18, 0 34 C -22 18, -22 -18, 0 -34 Z" fill="currentColor" opacity="0.35" />
<path d="M0 -30 V 30" {...stroke} />
</g>
))}
{motif === 'confetti' &&
Array.from({ length: 26 }, (_, i) => {
const x = round((i * 73) % 320);
const y = round((i * 47 + (i % 3) * 31) % 200);
return i % 3 === 0 ? <circle key={i} cx={x} cy={y} r="4" fill="currentColor" /> : <rect key={i} x={x} y={y} width="10" height="4" rx="2" fill="currentColor" transform={`rotate(${(i * 37) % 180} ${x + 5} ${y + 2})`} />;
})}
{motif === 'stars' &&
Array.from({ length: 14 }, (_, i) => {
const x = round((i * 61 + 20) % 300);
const y = round((i * 43 + 15) % 190);
const s = 4 + (i % 3) * 3;
return <path key={i} d={`M${x} ${y - s} L${x + s * 0.3} ${y - s * 0.3} L${x + s} ${y} L${x + s * 0.3} ${y + s * 0.3} L${x} ${y + s} L${x - s * 0.3} ${y + s * 0.3} L${x - s} ${y} L${x - s * 0.3} ${y - s * 0.3} Z`} fill="currentColor" />;
})}
</svg>
);
}
function GiftCard({ design, amount, brand, to, message, className, live }: { design: GiftDesign; amount?: number; brand?: string; to?: string; message?: string; className?: string; live?: boolean }) {
const locale = useLocale();
const t = useTexts(TEXTS);
return (
<div
className={cn('relative aspect-[1.6] w-full overflow-hidden rounded-2xl p-5 shadow-[0_24px_48px_-24px_rgb(0_0_0/0.6),inset_0_1px_0_rgb(255_255_255/0.3)] sm:p-6', design.dark ? 'text-[oklch(0.24_0.02_60)]' : 'text-white', className)}
style={{ background: `linear-gradient(135deg, ${design.from}, ${design.to})` }}
>
{design.art ? <div className="absolute inset-0 [&>*]:size-full [&>*]:object-cover">{design.art}</div> : <Motif motif={design.motif} />}
{/* A soft shade under the text, so name, message and amount read on any motif */}
<div aria-hidden className={cn('absolute inset-x-0 bottom-0 h-3/5', design.dark ? 'bg-[linear-gradient(to_top,rgb(255_255_255/0.35),transparent)]' : 'bg-[linear-gradient(to_top,rgb(0_0_0/0.32),transparent)]')} />
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(115deg,transparent_30%,rgb(255_255_255/0.22)_45%,transparent_60%)] bg-[length:250%_100%] bg-[position:120%_0] transition-[background-position] duration-[1200ms] ease-[var(--ease-kit)] group-hover/gc:bg-[position:-30%_0]" />
<div className="relative flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="text-[13px] font-semibold uppercase tracking-[0.12em] opacity-90">{brand}</span>
<span className="text-[12px] font-medium opacity-80">{t.giftCard}</span>
</div>
<div className="mt-auto">
{(to || message) && (
<p className={cn('mb-2 line-clamp-2 max-w-[85%] text-pretty text-[13px] leading-snug opacity-90', live && 'transition-opacity')}>
{to && <span className="font-semibold">{to}</span>}
{to && message && ' · '}
{message}
</p>
)}
{amount !== undefined && (
<Swap value={amount}>
<span className="text-[34px] font-semibold leading-none tracking-[-0.03em] tabular-nums sm:text-[40px]">{formatPrice(locale, amount).replace(/[.,]00(?=\s|$| )/, '')}</span>
</Swap>
)}
</div>
</div>
</div>
);
}
/** Tilts its child toward a mouse pointer, flat on touch and with reduced motion */
function Tilt({ children, className }: { children: React.ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div
ref={ref}
className={cn('transition-transform duration-500 ease-[var(--ease-kit)] [transform-style:preserve-3d] motion-reduce:!transform-none', className)}
onPointerMove={(e) => {
const el = ref.current;
if (!el || e.pointerType !== 'mouse') return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5;
const y = (e.clientY - r.top) / r.height - 0.5;
el.style.transform = `perspective(900px) rotateY(${(x * 10).toFixed(2)}deg) rotateX(${(-y * 10).toFixed(2)}deg) translateY(-4px)`;
}}
onPointerLeave={() => {
if (ref.current) ref.current.style.transform = '';
}}
>
{children}
</div>
);
}
/* ------------------------------------------------------------------ */
/* designer */
/* ------------------------------------------------------------------ */
function Designer(p: GiftCardsProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const designs = p.designs ?? [];
const amounts = p.amounts ?? [25, 50, 100];
const [lo, hi] = p.range ?? [10, 500];
const dv = p.defaultValues ?? {};
const [designId, setDesignId] = useState(dv.designId ?? designs[0]?.id ?? '');
const [amount, setAmount] = useState(dv.amount ?? amounts[1] ?? amounts[0] ?? 50);
const [custom, setCustom] = useState('');
const [to, setTo] = useState(dv.to ?? '');
const [email, setEmail] = useState('');
const [from, setFrom] = useState(dv.from ?? '');
const [message, setMessage] = useState(dv.message ?? '');
const [tried, setTried] = useState(false);
const [when, setWhen] = useState<'now' | 'later'>('now');
const [date, setDate] = useState('');
const [added, setAdded] = useState(false);
const id = useId();
const design = designs.find((d) => d.id === designId) ?? designs[0];
const customValue = Number(custom.replace(',', '.'));
const customOk = custom === '' || (customValue >= lo && customValue <= hi);
const shown = custom && customOk ? customValue : amount;
const valid = to.trim() && /\S+@\S+\.\S+/.test(email) && from.trim() && customOk && (when === 'now' || date);
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-16">
<div className="mx-auto w-full max-w-[30rem] lg:sticky lg:top-8 lg:max-w-none lg:self-start">
{design && (
<Reveal effect="scale">
<Tilt className="group/gc">
<div key={design.id} className="animate-[kitshop-zoom_0.5s_var(--ease-kit)]">
<GiftCard design={design} amount={shown} brand={p.brand} to={to} message={message} live />
</div>
</Tilt>
</Reveal>
)}
<p className="mt-5 flex items-start gap-2 text-[13.5px] text-muted-foreground">
<Icon name="shield" size={15} className="mt-0.5 shrink-0" />
{t.validFor}
</p>
</div>
<form
className="space-y-7"
onSubmit={(e) => {
e.preventDefault();
setTried(true);
if (!valid || !design) {
const first = !to.trim() ? `${id}-to` : !/S+@S+.S+/.test(email) ? `${id}-em` : !from.trim() ? `${id}-from` : null;
if (first) document.getElementById(first)?.focus();
return;
}
setAdded(true);
p.onBuy?.({ designId: design.id, amount: shown, to, from, message, date: when === 'now' ? '' : date });
}}
>
<fieldset>
<legend className="mb-3 text-[14px] font-medium text-foreground">{t.design}</legend>
<div role="radiogroup" aria-label={t.design} className="grid grid-cols-5 gap-1.5 sm:gap-2">
{designs.map((d) => (
<label key={d.id} className={cn('group/d relative cursor-pointer rounded-xl p-1 transition-shadow has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', d.id === designId ? 'shadow-[0_0_0_2px_var(--foreground)]' : 'hover:shadow-[0_0_0_1px_var(--border-strong)]')}>
<input type="radio" name={`${id}-d`} checked={d.id === designId} onChange={() => setDesignId(d.id)} className="sr-only" />
<span className="relative block aspect-[1.6] overflow-hidden rounded-lg transition-transform duration-300 group-hover/d:scale-[1.03]" style={{ background: `linear-gradient(135deg, ${d.from}, ${d.to})` }}>
<span className={cn('absolute inset-0', d.dark ? 'text-[oklch(0.24_0.02_60)]' : 'text-white')}>{d.art ?? <Motif motif={d.motif} />}</span>
</span>
<span className="mt-1.5 block truncate text-center text-[11.5px] text-muted-foreground sm:text-[12px]">{d.name}</span>
</label>
))}
</div>
</fieldset>
<fieldset>
<legend className="mb-3 text-[14px] font-medium text-foreground">{t.amount}</legend>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-[repeat(3,minmax(0,1fr))_minmax(0,1.3fr)]">
{amounts.map((a) => {
const on = !custom && a === amount;
return (
<label key={a} className={cn('grid h-12 cursor-pointer place-items-center rounded-lg border text-[15px] font-semibold tabular-nums transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', on ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-background text-foreground hover:bg-accent')}>
<input type="radio" name={`${id}-a`} checked={on} onChange={() => { setAmount(a); setCustom(''); }} className="sr-only" />
{formatPrice(locale, a, currency).replace(/[.,]00(?=\s|$| )/, '')}
</label>
);
})}
<div className="col-span-3 sm:col-span-1">
<label htmlFor={`${id}-c`} className="sr-only">
{t.other}
</label>
<span className="relative block">
<input
id={`${id}-c`}
value={custom}
onChange={(e) => setCustom(e.target.value.replace(/[^\d,.]/g, ''))}
inputMode="decimal"
placeholder={t.other}
aria-invalid={!customOk || undefined}
aria-describedby={`${id}-ch`}
className={cn(FIELD, 'h-12 pe-9 tabular-nums', custom && customOk && 'border-foreground', !customOk && 'border-destructive')}
/>
{custom && <span aria-hidden className="pointer-events-none absolute end-3.5 top-1/2 -translate-y-1/2 text-[14px] text-muted-foreground">{currency === 'USD' ? '$' : '€'}</span>}
</span>
</div>
</div>
<p id={`${id}-ch`} className={cn('mt-2 text-[12.5px]', customOk ? 'text-muted-foreground' : 'text-destructive')}>
{t.between} {formatPrice(locale, lo, currency).replace(/[.,]00(?=\s|$| )/, '')} {t.and} {formatPrice(locale, hi, currency).replace(/[.,]00(?=\s|$| )/, '')}
</p>
</fieldset>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor={`${id}-to`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.to}
</label>
<input id={`${id}-to`} value={to} onChange={(e) => setTo(e.target.value)} placeholder={t.toPlaceholder} maxLength={30} aria-invalid={(tried && !to.trim()) || undefined} className={cn(FIELD, tried && !to.trim() && 'border-destructive')} />
</div>
<div>
<label htmlFor={`${id}-em`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.toEmail}
</label>
<input id={`${id}-em`} type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={(tried && !/S+@S+.S+/.test(email)) || undefined} className={cn(FIELD, tried && !/S+@S+.S+/.test(email) && 'border-destructive')} />
</div>
<div className="sm:col-span-2">
<label htmlFor={`${id}-msg`} className="mb-1.5 flex items-baseline justify-between text-[13.5px] font-medium text-foreground">
{t.message}
<span className="text-[12px] font-normal tabular-nums text-muted-foreground">{message.length} / 140</span>
</label>
<textarea id={`${id}-msg`} value={message} onChange={(e) => setMessage(e.target.value.slice(0, 140))} rows={3} placeholder={t.messagePlaceholder} className={cn(FIELD, 'h-auto resize-none py-3 leading-relaxed')} />
</div>
<div>
<label htmlFor={`${id}-from`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.from}
</label>
<input id={`${id}-from`} value={from} onChange={(e) => setFrom(e.target.value)} placeholder={t.fromPlaceholder} aria-invalid={(tried && !from.trim()) || undefined} className={cn(FIELD, tried && !from.trim() && 'border-destructive')} />
</div>
<fieldset>
<legend className="mb-1.5 text-[13.5px] font-medium text-foreground">{t.sendOn}</legend>
<div className="relative grid h-11 grid-cols-2 rounded-lg border border-border-strong bg-surface p-1">
<span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]" style={{ transform: `translateX(${when === 'later' ? 100 : 0}%)` }} />
{(['now', 'later'] as const).map((w) => (
<label key={w} className={cn('relative z-10 grid cursor-pointer place-items-center after:absolute after:inset-x-0 after:-inset-y-[5px] after:content-[""] rounded-md text-[13.5px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', when === w ? 'text-foreground' : 'text-muted-foreground')}>
<input type="radio" name={`${id}-w`} checked={when === w} onChange={() => setWhen(w)} className="sr-only" />
{w === 'now' ? t.today : t.later}
</label>
))}
</div>
</fieldset>
{when === 'later' && (
<div className="animate-[kitshop-in_0.35s_var(--ease-kit)] sm:col-span-2">
<label htmlFor={`${id}-date`} className="sr-only">
{t.sendOn}
</label>
<input id={`${id}-date`} type="date" value={date} onChange={(e) => setDate(e.target.value)} className={FIELD} />
</div>
)}
</div>
{tried && !valid && (
<p role="alert" className="flex items-start gap-2 text-[13.5px] text-destructive">
<Icon name="alert-circle" size={15} className="mt-0.5 shrink-0" />
{t.fillIn}
</p>
)}
<div className="flex flex-col gap-3 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
<TweenPrice value={shown} currency={currency} className="text-[26px] font-semibold tracking-[-0.02em] text-foreground" />
<Button type="submit" size="lg" disabled={added} iconLeft={added ? 'check' : 'gift'}>
<Swap value={added}>{added ? t.added : t.buy}</Swap>
</Button>
</div>
</form>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* amounts */
/* ------------------------------------------------------------------ */
function Amounts(p: GiftCardsProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const designs = p.designs ?? [];
const offers = p.offers ?? [];
const [picked, setPicked] = useState<Record<string, boolean>>({});
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} center />
<Stagger step={90} as="ul" className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{offers.map((o, i) => {
const d = designs.find((x) => x.id === o.designId) ?? designs[i % Math.max(1, designs.length)];
const on = !!picked[o.id];
return (
<li key={o.id} className="flex flex-col">
{d && (
<Tilt className="group/gc">
<GiftCard design={d} amount={o.amount} brand={p.brand} />
</Tilt>
)}
<div className="mt-4 flex flex-1 flex-col px-1">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-[16px] font-semibold text-foreground">{o.occasion ?? d?.name}</h3>
<span className="text-[15px] font-semibold tabular-nums text-foreground">{formatPrice(locale, o.amount, currency)}</span>
</div>
{o.note && <p className="mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{o.note}</p>}
<div className="mt-auto pt-4">
<Button variant={on ? 'secondary' : 'outline'} className="w-full" iconLeft={on ? 'check' : 'gift'} aria-pressed={on} onClick={() => setPicked((x) => ({ ...x, [o.id]: !on }))}>
<Swap value={on}>{on ? t.chosen : t.choose}</Swap>
</Button>
</div>
</div>
</li>
);
})}
</Stagger>
<p className="mt-10 text-center text-[13.5px] text-muted-foreground">{t.validFor}</p>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* balance */
/* ------------------------------------------------------------------ */
const formatCode = (v: string) => v.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 12).replace(/(.{4})(?=.)/g, '$1 ');
function Balance(p: GiftCardsProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const bal = p.balance;
const [code, setCode] = useState(p.defaultChecked && bal ? formatCode(bal.code) : '');
const [state, setState] = useState<'idle' | 'checking' | 'done' | 'wrong'>(p.defaultChecked ? 'done' : 'idle');
const [shake, setShake] = useState(0);
const id = useId();
const share = bal ? Math.max(0, Math.min(1, bal.amount / Math.max(1, bal.initial))) : 0;
const ring = round(2 * Math.PI * 54);
const check = (e: React.FormEvent) => {
e.preventDefault();
setState('checking');
window.setTimeout(() => {
const ok = !!bal && code.replace(/\s/g, '') === bal.code.replace(/\s/g, '').toUpperCase();
setState(ok ? 'done' : 'wrong');
if (!ok) setShake((s) => s + 1);
}, 700);
};
return (
<Section className={p.className}>
<ShopMotion />
<div className={cn(CARD, 'mx-auto grid max-w-[56rem] grid-cols-1 overflow-hidden md:grid-cols-2')}>
<form onSubmit={check} className="flex flex-col p-6 sm:p-8" key={`f${shake}`}>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<h2 className="text-balance text-h3 font-semibold text-foreground">{p.title}</h2>
{p.description && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{p.description}</p>}
<label htmlFor={id} className="mb-1.5 mt-7 block text-[13.5px] font-medium text-foreground">
{t.code}
</label>
<input
id={id}
value={code}
onChange={(e) => { setCode(formatCode(e.target.value)); if (state !== 'checking') setState('idle'); }}
placeholder={t.codePlaceholder}
autoComplete="off"
spellCheck={false}
aria-invalid={state === 'wrong' || undefined}
aria-describedby={state === 'wrong' ? `${id}-e` : undefined}
className={cn(FIELD, 'h-12 text-[17px] uppercase tracking-[0.14em] tabular-nums', state === 'wrong' && 'animate-[kitshop-shake_0.4s_var(--ease-kit)] border-destructive')}
/>
{state === 'wrong' && (
<p id={`${id}-e`} role="alert" className="mt-2 flex items-start gap-1.5 text-[13px] text-destructive">
<Icon name="alert-circle" size={14} className="mt-0.5 shrink-0" />
{t.notFound}
</p>
)}
<Button type="submit" size="lg" className="mt-5 w-full" iconLeft={state === 'checking' ? 'loader' : 'search'} disabled={state === 'checking' || code.replace(/\s/g, '').length < 12}>
<Swap value={state === 'checking'}>{state === 'checking' ? t.checking : t.check}</Swap>
</Button>
</form>
<div className="relative border-t border-border bg-surface p-6 sm:p-8 md:border-l md:border-t-0" aria-live="polite">
{state === 'done' && bal ? (
<div className="animate-[kitshop-in_0.5s_var(--ease-kit)]">
<div className="flex items-center gap-5 sm:gap-6">
<span className="relative grid size-28 shrink-0 place-items-center sm:size-32">
<svg viewBox="0 0 120 120" className="absolute inset-0 -rotate-90" aria-hidden>
<circle cx="60" cy="60" r="54" fill="none" strokeWidth="8" className="stroke-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
<circle cx="60" cy="60" r="54" fill="none" strokeWidth="8" strokeLinecap="round" className="stroke-primary [animation:kitshop-draw_1.2s_var(--ease-kit)_both]" strokeDasharray={ring} strokeDashoffset={round(ring * (1 - share))} style={{ '--kitshop-len': ring } as React.CSSProperties} />
</svg>
<span className="relative text-center">
<span className="block text-[22px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{formatPrice(locale, bal.amount, currency)}</span>
<span className="mt-1 block text-[12px] text-muted-foreground">{t.left}</span>
</span>
</span>
<div className="min-w-0 text-[14px] text-muted-foreground">
<p>
{t.of} <span className="font-medium tabular-nums text-foreground">{formatPrice(locale, bal.initial, currency)}</span>
</p>
{bal.expires && (
<p className="mt-2">
{t.expires}
<span className="block font-medium text-foreground">{bal.expires}</span>
</p>
)}
</div>
</div>
{bal.history?.length ? (
<ul className="mt-6 divide-y divide-border border-t border-border">
{bal.history.map((h, i) => (
<li key={i} className="flex items-center justify-between gap-4 py-3 text-[14px] animate-[kitshop-in_0.4s_var(--ease-kit)_both]" style={{ animationDelay: `${200 + i * 70}ms` }}>
<span className="min-w-0">
<span className="block truncate text-foreground">{h.text}</span>
<span className="block text-[12.5px] text-muted-foreground">{h.date}</span>
</span>
<span className={cn('shrink-0 font-medium tabular-nums', h.amount < 0 ? 'text-foreground' : 'text-success-foreground')}>
{h.amount < 0 ? '-' : '+'}
{formatPrice(locale, Math.abs(h.amount), currency)}
</span>
</li>
))}
</ul>
) : null}
<Button variant="ghost" className="mt-3 -ms-3" onClick={() => { setState('idle'); setCode(''); }}>
{t.checkAnother}
</Button>
</div>
) : (
<div className="grid h-full min-h-[14rem] place-items-center">
{(p.designs ?? [])[0] && (
<div className="w-full max-w-[18rem] -rotate-3 opacity-90 transition-transform duration-700 ease-[var(--ease-kit)] hover:rotate-0">
<GiftCard design={(p.designs ?? [])[0]} brand={p.brand} />
</div>
)}
</div>
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* redeem */
/* ------------------------------------------------------------------ */
function Redeem(p: GiftCardsProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const cart = p.cart ?? { subtotal: 0, validCode: '', codeValue: 0 };
const [open, setOpen] = useState(true);
const [code, setCode] = useState(p.defaultCode ? formatCode(p.defaultCode) : '');
const [applied, setApplied] = useState(false);
const [wrong, setWrong] = useState(0);
const id = useId();
const before = cart.subtotal + (cart.shipping ?? 0);
const used = applied ? Math.min(cart.codeValue, before) : 0;
const total = before - used;
const rest = applied ? Math.max(0, cart.codeValue - before) : 0;
const apply = (e: React.FormEvent) => {
e.preventDefault();
if (code.replace(/\s/g, '') === cart.validCode.replace(/\s/g, '').toUpperCase()) {
setApplied(true);
} else setWrong((w) => w + 1);
};
return (
<Section className={p.className}>
<ShopMotion />
<div className="mx-auto max-w-[28rem]">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className={cn(CARD, 'p-5 sm:p-6')}>
<dl className="space-y-2.5 text-[14.5px]">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{t.subtotal}</dt>
<dd className="tabular-nums text-foreground">{formatPrice(locale, cart.subtotal, currency)}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{t.shipping}</dt>
<dd className={cn('tabular-nums', cart.shipping ? 'text-foreground' : 'text-success-foreground')}>{cart.shipping ? formatPrice(locale, cart.shipping, currency) : t.free}</dd>
</div>
{applied && (
<div className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] justify-between gap-4">
<dt className="flex items-center gap-1.5 text-success-foreground">
<Icon name="gift" size={14} />
{t.giftCard} ·· {cart.validCode.slice(-4)}
</dt>
<dd className="tabular-nums text-success-foreground">-{formatPrice(locale, used, currency)}</dd>
</div>
)}
</dl>
<div className="mt-4 border-t border-border pt-4">
{applied ? (
<div className="flex animate-[kitshop-pop_0.4s_var(--ease-kit)] items-center justify-between gap-3 rounded-xl bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-3.5 py-2.5">
<span className="flex items-center gap-2 text-[13.5px] font-medium text-success-foreground">
<Icon name="check-circle" size={16} />
{t.applied}
</span>
<button type="button" onClick={() => { setApplied(false); setCode(''); }} aria-label={t.remove} className={cn('grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}>
<Icon name="x" size={15} />
</button>
</div>
) : (
<>
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)} className={cn('flex min-h-11 w-full items-center justify-between gap-3 rounded-md text-[14.5px] font-medium text-foreground', FOCUS)}>
<span className="flex items-center gap-2">
<Icon name="gift" size={16} className="text-muted-foreground" />
{t.haveCard}
</span>
<Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', open && 'rotate-180')} />
</button>
<div className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<form onSubmit={apply} className="overflow-hidden">
<div className="flex gap-2 pt-2" key={`w${wrong}`}>
<label htmlFor={id} className="sr-only">
{t.code}
</label>
<input
id={id}
value={code}
onChange={(e) => setCode(formatCode(e.target.value))}
placeholder={t.codePlaceholder}
spellCheck={false}
autoComplete="off"
aria-invalid={wrong > 0 || undefined}
aria-describedby={wrong ? `${id}-e` : undefined}
className={cn(FIELD, 'w-0 min-w-0 flex-1 uppercase tracking-[0.1em] tabular-nums', wrong > 0 && 'animate-[kitshop-shake_0.4s_var(--ease-kit)] border-destructive')}
/>
<Button type="submit" variant="outline" disabled={code.replace(/\s/g, '').length < 12}>
{t.apply}
</Button>
</div>
{wrong > 0 && (
<p id={`${id}-e`} role="alert" className="mt-2 text-[12.5px] text-destructive">
{t.wrongCode}
</p>
)}
</form>
</div>
</>
)}
</div>
<div className="mt-4 flex items-baseline justify-between gap-4 border-t border-border pt-4">
<span className="text-[15px] font-semibold text-foreground">{t.total}</span>
<TweenPrice value={total} currency={currency} className="text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
{rest > 0 && (
<p className="mt-1 animate-[kitshop-fade_0.4s] text-right text-[12.5px] text-muted-foreground">
<span className="tabular-nums text-foreground">{formatPrice(locale, rest, currency)}</span> {t.stillOnCard}
</p>
)}
<Button size="lg" className="mt-5 w-full" icon="arrow-right">
{t.pay}
</Button>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Gift cards in four layouts. designer: design and send one. amounts: ready
* cards by occasion. balance: check what is left. redeem: apply a code in the cart.
*/
export function GiftCards(props: GiftCardsProps) {
const { variant = 'designer' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'amounts') return <Amounts {...props} t={t} />;
if (variant === 'balance') return <Balance {...props} t={t} />;
if (variant === 'redeem') return <Redeem {...props} t={t} />;
return <Designer {...props} t={t} />;
}Frequent questions
How do I install Gift cards?
With aiw add @ai-website/gift-cards. The command puts components/ui/gift-cards.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section and product-image. It needs no further npm packages.
Which variants does Gift cards have?
4 variants, Gift card designer, Ready gift cards, Check balance and Redeem in the cart. You pick one with the variant prop, for example variant="amounts".
Can I translate the texts of Gift cards?
Yes. Gift cards carries 43 texts of its own in English and German, such as “Gift card”, “Design” and “Amount”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Gift cards?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Gift cards with aiw_components and install it with aiw_add @ai-website/gift-cards. Name the variant in your prompt, for example variant="designer", and the assistant writes your content straight into the props.
Can I adjust Gift cards without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching GiftCards code to copy.