Flash sale with countdown
A sale with a ticking clock and stock that runs low. A banner, a deal grid, a deal of the day and timed drops with a reminder.
A clock, a stock bar and one clear offer
A flash sale lives on two signals, time running and stock running low. This component shows both, digits that roll on every second and bars that fill as pieces are claimed, and after the end it says when the next deals start.
Countdown banner is a dark band with drifting light, large digits and one call to action. Deal grid shows four deals with the clock in the header, each claimed bar fills on scroll, turns warm from 80 percent with the pieces left and greys out the card when sold out. Deal of the day sets one product large with a ring on its corner that empties with the time left, the stock bar and a quantity capped at the limit per customer. Timed drops show the slots of a day as tabs with arrow keys, the live one can be bought, later ones show a blurred first look and take an email for a reminder.
Point every countdown at a real end, that honesty brings people back. Use timed drops for launches with several slots in one day. Keep the banner on the home page and the deal grid on a sale page of its own.
Variants
Details
What Flash sale brings along, read from the registry and the source.
- Category
- Sales and promotions
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/flash-sale.tsx
- Brings along
- utilskitbuttoniconproduct-imageinput
- npm packages
- None
- Languages
- English and German, 31 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FlashSaleProps interface from components/ui/flash-sale.tsx, with the comments from the code.
variantoptionalFlashSaleVariantbannerdeal-griddeal-of-daydrops
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringendsInoptionalnumberSeconds until the sale ends
dealsoptionalFlashDeal[]dropsoptionalFlashDrop[]limitoptionalnumberMost pieces per customer (deal-of-day)
ctaoptional{ label: string; href: string }onAddoptional(deal: FlashDeal, quantity: number) => voidonNotifyoptional(email: string, time: string) => voidcurrencyoptionalstring
Built-in texts
Flash sale writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 31 texts
| Key | English | German |
|---|---|---|
| endsIn | English Ends in | German Endet in |
| ended | English This sale has ended | German Diese Aktion ist vorbei |
| endedText | English New deals start every day at 9:00. | German Neue Angebote starten jeden Tag um 09:00. |
| days | English Days | German Tage |
| hours | English Hours | German Std. |
| minutes | English Min | German Min. |
| seconds | English Sec | German Sek. |
| claimed | English {pct} claimed | German {pct} vergriffen |
| almostGone | English Almost gone | German Fast weg |
| soldOut | English Sold out | German Ausverkauft |
| onlyLeft | English Only {n} left | German Nur noch {n} |
| add | English Add to cart | German In den Warenkorb |
| added | English Added | German Im Warenkorb |
| addNamed | English Add {name} to cart | German {name} in den Warenkorb |
| save | English Save {amount} | German Spare {amount} |
| off | English −{pct} | German −{pct} |
| limit | English Up to {n} per customer | German Bis zu {n} pro Person |
| quantity | English Quantity | German Menge |
| live | English Live now | German Jetzt live |
| next | English Next | German Als Nächstes |
| later | English Later | German Später |
| startsAt | English Starts at {time} | German Startet um {time} |
| notifyTitle | English Get a reminder | German Erinnerung bekommen |
| notifyText | English We send one email when this drop goes live. Nothing else. | German Wir schicken eine E-Mail, wenn dieser Drop live geht. Sonst nichts. |
| English Email address | German E-Mail-Adresse | |
| notify | English Remind me | German Erinnere mich |
| notified | English Done. We write to {email} at {time}. | German Erledigt. Wir schreiben dir um {time} an {email}. |
| eEmpty | English Enter your email address for the reminder. | German Gib deine E-Mail-Adresse für die Erinnerung ein. |
| eEmail | English This email address looks incomplete. Check the part after the @. | German Diese E-Mail-Adresse wirkt unvollständig. Prüf den Teil nach dem @. |
| preview | English A first look | German Ein erster Blick |
| slots | English Drops today | German Drops heute |
Accessibility
Read from the source of the component.
- Flash sale handles the keys Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-controls, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-selected, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
- Uses the roles progressbar, status, tab, tablist, tabpanel and timer.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- 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. 3 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, 643 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, formatTime, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { FlyUp, ProductMedia, Quantity, ShopMotion, Swap, formatPrice } from '@/components/ui/product-image';
/*
* A sale with a clock and a stock that runs low.
*
* banner a dark band with the countdown in large digits and one call to action
* deal-grid deal cards with a claimed bar each, "almost gone" from 80 percent, sold out at 100
* deal-of-day one product large, the countdown, stock left and a limit per customer
* drops time slots as tabs, the live one shows its deals, the next ones take an email for a reminder
*
* `endsIn` counts down from mount, so server and browser show the same first value.
*/
export type FlashSaleVariant = 'banner' | 'deal-grid' | 'deal-of-day' | 'drops';
export interface FlashDeal {
id: string;
name: string;
subtitle?: string;
href?: string;
image?: React.ReactNode;
price: number;
compareAt: number;
/** Share already sold, 0 to 100 */
claimed?: number;
/** Pieces left, shown when low */
left?: number;
}
export interface FlashDrop {
/** "HH:MM" */
time: string;
label?: string;
status: 'live' | 'next' | 'later';
deals: FlashDeal[];
}
export interface FlashSaleProps {
variant?: FlashSaleVariant;
eyebrow?: string;
title?: string;
description?: string;
/** Seconds until the sale ends */
endsIn?: number;
deals?: FlashDeal[];
drops?: FlashDrop[];
/** Most pieces per customer (deal-of-day) */
limit?: number;
cta?: { label: string; href: string };
onAdd?: (deal: FlashDeal, quantity: number) => void;
onNotify?: (email: string, time: string) => void;
currency?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
endsIn: 'Ends in',
ended: 'This sale has ended',
endedText: 'New deals start every day at 9:00.',
days: 'Days',
hours: 'Hours',
minutes: 'Min',
seconds: 'Sec',
claimed: '{pct} claimed',
almostGone: 'Almost gone',
soldOut: 'Sold out',
onlyLeft: 'Only {n} left',
add: 'Add to cart',
added: 'Added',
addNamed: 'Add {name} to cart',
save: 'Save {amount}',
off: '−{pct}',
limit: 'Up to {n} per customer',
quantity: 'Quantity',
live: 'Live now',
next: 'Next',
later: 'Later',
startsAt: 'Starts at {time}',
notifyTitle: 'Get a reminder',
notifyText: 'We send one email when this drop goes live. Nothing else.',
email: 'Email address',
notify: 'Remind me',
notified: 'Done. We write to {email} at {time}.',
eEmpty: 'Enter your email address for the reminder.',
eEmail: 'This email address looks incomplete. Check the part after the @.',
preview: 'A first look',
slots: 'Drops today',
},
de: {
endsIn: 'Endet in',
ended: 'Diese Aktion ist vorbei',
endedText: 'Neue Angebote starten jeden Tag um 09:00.',
days: 'Tage',
hours: 'Std.',
minutes: 'Min.',
seconds: 'Sek.',
claimed: '{pct} vergriffen',
almostGone: 'Fast weg',
soldOut: 'Ausverkauft',
onlyLeft: 'Nur noch {n}',
add: 'In den Warenkorb',
added: 'Im Warenkorb',
addNamed: '{name} in den Warenkorb',
save: 'Spare {amount}',
off: '−{pct}',
limit: 'Bis zu {n} pro Person',
quantity: 'Menge',
live: 'Jetzt live',
next: 'Als Nächstes',
later: 'Später',
startsAt: 'Startet um {time}',
notifyTitle: 'Erinnerung bekommen',
notifyText: 'Wir schicken eine E-Mail, wenn dieser Drop live geht. Sonst nichts.',
email: 'E-Mail-Adresse',
notify: 'Erinnere mich',
notified: 'Erledigt. Wir schreiben dir um {time} an {email}.',
eEmpty: 'Gib deine E-Mail-Adresse für die Erinnerung ein.',
eEmail: 'Diese E-Mail-Adresse wirkt unvollständig. Prüf den Teil nach dem @.',
preview: 'Ein erster Blick',
slots: 'Drops heute',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const pctOff = (d: FlashDeal) => Math.round((1 - d.price / d.compareAt) * 100);
/** Seconds left, ticking once a second from mount */
function useCountdown(endsIn = 0) {
const [left, setLeft] = useState(endsIn);
useEffect(() => {
const end = Date.now() + endsIn * 1000;
setLeft(endsIn);
const id = setInterval(() => setLeft(Math.max(0, Math.round((end - Date.now()) / 1000))), 1000);
return () => clearInterval(id);
}, [endsIn]);
return left;
}
const parts = (s: number) => ({ d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 });
const two = (n: number) => String(n).padStart(2, '0');
/** One unit of the clock, the digits roll when they change */
function Unit({ value, label, size }: { value: number; label: string; size: 'xl' | 'md' | 'sm' }) {
const first = useRef(value);
const text = two(value);
return (
<div className="flex flex-col items-center">
<span
className={cn(
'relative grid place-items-center overflow-hidden rounded-xl font-semibold tabular-nums [clip-path:inset(0_round_0.75rem)]',
size === 'xl' && 'h-16 w-14 bg-white/8 text-[34px] ring-1 ring-white/12 sm:h-24 sm:w-[5.25rem] sm:text-[52px]',
size === 'md' && 'h-14 w-14 bg-surface-2 text-[28px] text-foreground ring-1 ring-border',
size === 'sm' && 'h-9 w-10 rounded-lg bg-foreground text-[17px] text-background',
)}
>
<span key={text} className={cn('block', text !== two(first.current) && 'animate-[kitshop-up_0.42s_var(--ease-kit)]')}>
{text}
</span>
</span>
<span className={cn('mt-1.5 text-[11px] font-medium uppercase tracking-[0.08em]', size === 'xl' ? 'text-white/60' : 'text-muted-foreground')}>{label}</span>
</div>
);
}
function Clock({ left, t, size = 'md', className }: { left: number; t: Texts; size?: 'xl' | 'md' | 'sm'; className?: string }) {
const p = parts(left);
const units = [
...(p.d ? [{ v: p.d, l: t.days }] : []),
{ v: p.h, l: t.hours },
{ v: p.m, l: t.minutes },
{ v: p.s, l: t.seconds },
];
const spoken = `${p.d ? `${p.d} ${t.days}, ` : ''}${p.h} ${t.hours}, ${p.m} ${t.minutes}`;
return (
<div className={cn('flex items-start', size === 'sm' ? 'gap-1' : 'gap-1.5 sm:gap-3', className)} role="timer" aria-label={`${t.endsIn} ${spoken}`}>
{units.map((u, i) => (
<div key={u.l} className="flex items-start gap-1.5 sm:gap-3" aria-hidden>
{i > 0 && size !== 'sm' && <span className={cn('font-semibold', size === 'xl' ? 'mt-3 text-[24px] text-white/35 sm:mt-6 sm:text-[36px]' : 'mt-3 text-[22px] text-border-strong')}>:</span>}
<Unit value={u.v} label={u.l} size={size} />
</div>
))}
</div>
);
}
function Claimed({ deal, t, className }: { deal: FlashDeal; t: Texts; className?: string }) {
const locale = useLocale();
const [ref, inView] = useInView<HTMLDivElement>();
const c = deal.claimed ?? 0;
const hot = c >= 80;
return (
<div ref={ref} className={className}>
<div className="mb-1.5 flex items-center justify-between gap-2 text-[12px]">
<span className={cn('font-medium', hot ? 'text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]' : 'text-muted-foreground')}>
{c >= 100 ? t.soldOut : hot ? (deal.left ? fill(t.onlyLeft, { n: deal.left }) : t.almostGone) : fill(t.claimed, { pct: formatPercent(c, locale) })}
</span>
{hot && c < 100 && <span className="tabular-nums text-muted-foreground">{formatPercent(c, locale)}</span>}
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-track" role="progressbar" aria-label={fill(t.claimed, { pct: '' }).trim()} aria-valuenow={c} aria-valuemin={0} aria-valuemax={100}>
<div className={cn('h-full origin-left rounded-full transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none', hot ? 'bg-[color-mix(in_oklch,var(--destructive)_85%,var(--primary))]' : 'bg-primary')} style={{ transform: `scaleX(${inView ? c / 100 : 0})` }} />
</div>
</div>
);
}
function OffBadge({ deal, t, className }: { deal: FlashDeal; t: Texts; className?: string }) {
const locale = useLocale();
return <span className={cn('inline-flex h-6 items-center rounded-md bg-[color-mix(in_oklch,var(--destructive)_88%,black)] px-2 text-[12px] font-semibold tabular-nums text-white', className)}>{fill(t.off, { pct: formatPercent(pctOff(deal), locale) })}</span>;
}
function PriceRow({ deal, currency, size = 'md' }: { deal: FlashDeal; currency: string; size?: 'md' | 'xl' }) {
const locale = useLocale();
return (
<p className="flex flex-wrap items-baseline gap-x-2 tabular-nums">
<span className={cn('font-semibold text-foreground', size === 'xl' ? 'text-[32px] tracking-[-0.03em]' : 'text-[16px]')}>{formatPrice(locale, deal.price, currency)}</span>
<s className={cn('text-muted-foreground', size === 'xl' ? 'text-[17px]' : 'text-[13px]')}>{formatPrice(locale, deal.compareAt, currency)}</s>
</p>
);
}
function AddButton({ deal, t, onAdd, quantity = 1, size = 'md', className }: { deal: FlashDeal; t: Texts; onAdd?: FlashSaleProps['onAdd']; quantity?: number; size?: 'md' | 'lg'; className?: string }) {
const [added, setAdded] = useState(false);
const [fly, setFly] = useState(0);
const out = (deal.claimed ?? 0) >= 100;
return (
<span className={cn('relative block', className)}>
<Button
size={size}
variant={out ? 'secondary' : 'primary'}
disabled={out}
aria-label={out ? `${t.soldOut}, ${deal.name}` : fill(t.addNamed, { name: deal.name })}
onClick={() => { setAdded(true); setFly((f) => f + 1); onAdd?.(deal, quantity); }}
className="w-full overflow-hidden"
>
<Swap value={out ? 'out' : added} className="gap-2">
<Icon name={out ? 'ban' : added ? 'check' : 'bag'} size={16} />
{out ? t.soldOut : added ? t.added : t.add}
</Swap>
</Button>
<FlyUp count={fly} label={`+${quantity}`} className="-top-5" />
</span>
);
}
function Head({ props, left, t }: { props: FlashSaleProps; left: number; t: Texts }) {
return (
<div className="mb-8 flex flex-col gap-6 sm:mb-10 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[38rem]">
{props.eyebrow && (
<p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-[color-mix(in_oklch,var(--destructive)_75%,var(--foreground))]">
<Icon name="bolt" size={15} />
{props.eyebrow}
</p>
)}
{props.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>}
{props.description && <p className="mt-3 text-pretty text-[16px] text-muted-foreground">{props.description}</p>}
</div>
{left > 0 ? (
<div className="shrink-0">
<p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.endsIn}</p>
<Clock left={left} t={t} size="md" />
</div>
) : (
<p className="text-[14px] font-medium text-muted-foreground">{t.ended}</p>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* banner */
/* ------------------------------------------------------------------ */
function Banner(props: FlashSaleProps & { t: Texts }) {
const { t } = props;
const left = useCountdown(props.endsIn);
return (
<section className={cn('py-10 sm:py-14', props.className)}>
<div className="kit-container">
<div data-clip-ok className="dark relative isolate overflow-hidden rounded-3xl bg-bezel px-6 py-10 text-white sm:px-10 sm:py-12 lg:px-14">
<div aria-hidden className="absolute -right-24 -top-32 -z-10 size-[28rem] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_55%,transparent),transparent)] opacity-70 motion-safe:animate-[kit-float_9s_ease-in-out_infinite]" />
<div aria-hidden className="absolute -bottom-40 -left-24 -z-10 size-[26rem] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--destructive)_40%,transparent),transparent)] opacity-60" />
<svg aria-hidden viewBox="0 0 400 400" className="absolute -right-10 bottom-0 -z-10 h-full opacity-[0.07] max-md:hidden">
<path d="M230 20 120 220h80l-30 160 140-220h-90Z" fill="white" />
</svg>
<div className="grid grid-cols-1 items-center gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-14">
<div className="kit-rise">
{props.eyebrow && (
<p className="inline-flex h-7 items-center gap-1.5 rounded-full bg-white/10 px-3 text-[12px] font-semibold uppercase tracking-[0.1em] text-white ring-1 ring-white/15">
<Icon name="bolt" size={13} />
{props.eyebrow}
</p>
)}
{props.title && <h2 className="mt-5 text-balance text-h1 font-semibold text-white">{props.title}</h2>}
{props.description && <p className="mt-4 max-w-[32rem] text-pretty text-[16px] text-white/70">{props.description}</p>}
{props.cta && left > 0 && (
<Button href={props.cta.href} size="lg" icon="arrow-right" magnetic className="mt-8 max-sm:w-full">
{props.cta.label}
</Button>
)}
</div>
<div className="kit-rise" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>
{left > 0 ? (
<>
<p className="mb-3 text-[13px] font-medium text-white/60 lg:text-center">{t.endsIn}</p>
<Clock left={left} t={t} size="xl" />
</>
) : (
<div className="rounded-2xl bg-white/8 p-6 ring-1 ring-white/12">
<p className="text-[17px] font-semibold text-white">{t.ended}</p>
<p className="mt-1 text-[14px] text-white/65">{t.endedText}</p>
</div>
)}
</div>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* deal-grid */
/* ------------------------------------------------------------------ */
function DealGrid(props: FlashSaleProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const left = useCountdown(props.endsIn);
const deals = props.deals ?? [];
return (
<section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
<ShopMotion />
<div className="kit-container">
<Head props={props} left={left} t={t} />
<ul className="grid grid-cols-1 gap-4 min-[480px]:grid-cols-2 lg:grid-cols-4 lg:gap-5">
{deals.map((d, i) => {
const out = (d.claimed ?? 0) >= 100;
return (
<li key={d.id} className="kit-rise group/deal flex flex-col rounded-2xl border border-border bg-card p-3 shadow-[var(--shadow-sm)] transition-shadow hover:shadow-[var(--shadow-md)]" style={{ '--kit-delay': `${i * 70}ms` } as React.CSSProperties}>
<div className="relative">
<ProductMedia className={cn('aspect-square rounded-xl [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/deal:[&>*]:scale-[1.05] motion-reduce:[&>*]:transition-none', out && 'opacity-55 grayscale')}>{d.image}</ProductMedia>
<OffBadge deal={d} t={t} className="absolute left-2.5 top-2.5" />
</div>
<div className="flex flex-1 flex-col px-1 pt-3">
<a href={d.href ?? '#'} className="text-[15px] font-medium text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
{d.name}
</a>
{d.subtitle && <p className="text-[13px] text-muted-foreground">{d.subtitle}</p>}
<div className="mt-2">
<PriceRow deal={d} currency={currency} />
<p className="text-[12px] font-medium text-success-foreground">{fill(t.save, { amount: formatPrice(locale, round(d.compareAt - d.price), currency) })}</p>
</div>
<Claimed deal={d} t={t} className="mb-4 mt-4" />
<AddButton deal={d} t={t} onAdd={props.onAdd} className="mt-auto" />
</div>
</li>
);
})}
</ul>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* deal-of-day */
/* ------------------------------------------------------------------ */
function DealOfDay(props: FlashSaleProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const left = useCountdown(props.endsIn);
const d = props.deals?.[0];
const max = props.limit ?? 2;
const [qty, setQty] = useState(1);
if (!d) return null;
const total = props.endsIn ?? 1;
const ring = Math.max(0, Math.min(1, left / Math.max(1, total)));
const C = 2 * Math.PI * 46;
return (
<section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="grid grid-cols-1 items-center gap-8 md:grid-cols-2 lg:gap-16">
<div className="relative">
<ProductMedia className="aspect-square rounded-3xl max-md:aspect-[5/4]">{d.image}</ProductMedia>
<div className="absolute left-4 top-4 flex flex-col items-start gap-2 sm:left-5 sm:top-5">
<OffBadge deal={d} t={t} className="h-8 px-3 text-[14px]" />
</div>
{/* The ring of time left sits on the corner of the picture, over its quiet backdrop */}
{left > 0 && (
<div className="absolute bottom-4 right-4 grid size-24 place-items-center rounded-full bg-background/90 shadow-[var(--shadow-lg)] backdrop-blur sm:bottom-5 sm:right-5 sm:size-28" aria-hidden>
<svg viewBox="0 0 100 100" className="absolute inset-1.5 -rotate-90">
<circle cx="50" cy="50" r="46" fill="none" strokeWidth="4" className="stroke-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" />
<circle cx="50" cy="50" r="46" fill="none" strokeWidth="4" strokeLinecap="round" strokeDasharray={`${round(C * ring)} ${round(C)}`} className="stroke-primary transition-[stroke-dasharray] duration-1000 ease-linear" />
</svg>
<span className="relative text-center">
<span className="block text-[18px] font-semibold tabular-nums text-foreground sm:text-[20px]">
{two(parts(left).h + parts(left).d * 24)}:{two(parts(left).m)}
</span>
<span className="block text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{t.endsIn}</span>
</span>
</div>
)}
</div>
<div className="min-w-0">
{props.eyebrow && (
<p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-[color-mix(in_oklch,var(--destructive)_75%,var(--foreground))]">
<Icon name="bolt" size={15} />
{props.eyebrow}
</p>
)}
<h2 className="text-balance text-h2 font-semibold text-foreground">{d.name}</h2>
{d.subtitle && <p className="mt-2 text-[16px] text-muted-foreground">{d.subtitle}</p>}
<div className="mt-6">
<PriceRow deal={d} currency={currency} size="xl" />
<p className="mt-1 text-[14px] font-medium text-success-foreground">{fill(t.save, { amount: formatPrice(locale, round(d.compareAt - d.price), currency) })}</p>
</div>
<div className="mt-7">
{left > 0 ? (
<>
<p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.endsIn}</p>
<Clock left={left} t={t} size="sm" />
</>
) : (
<p className="text-[14px] font-medium text-muted-foreground">{t.ended}</p>
)}
</div>
<Claimed deal={d} t={t} className="mt-7 max-w-[26rem]" />
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
<Quantity value={qty} onChange={setQty} max={max} label={t.quantity} />
<AddButton deal={{ ...d, claimed: left > 0 ? d.claimed : 100 }} t={t} onAdd={props.onAdd} quantity={qty} size="lg" className="sm:flex-1" />
</div>
<p className="mt-3 flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="info" size={14} />
{fill(t.limit, { n: max })}
</p>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* drops */
/* ------------------------------------------------------------------ */
function Notify({ time, t, onNotify }: { time: string; t: Texts; onNotify?: FlashSaleProps['onNotify'] }) {
const locale = useLocale();
const id = useId();
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const row = useRef<HTMLFormElement>(null);
const input = useRef<HTMLInputElement>(null);
useShake(row, !!error);
if (done)
return (
<p role="status" className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] items-start gap-2 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[14px] text-foreground">
<Icon name="check-circle" size={17} className="mt-0.5 shrink-0 text-success" />
{fill(t.notified, { email, time: formatTime(time, locale) })}
</p>
);
return (
<form
ref={row}
noValidate
onSubmit={(e) => {
e.preventDefault();
const v = email.trim();
if (!v) return (setError(t.eEmpty), input.current?.focus());
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) return (setError(t.eEmail), input.current?.focus());
setError('');
setDone(true);
onNotify?.(v, time);
}}
>
<label htmlFor={id} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.email}
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input ref={input} id={id} type="email" autoComplete="email" value={email} onChange={(e) => { setEmail(e.target.value); setError(''); }} aria-invalid={!!error || undefined} aria-describedby={error ? `${id}-e` : undefined} placeholder="name@example.com" className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3.5 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:flex-1 sm:text-[14px]" />
<Button type="submit" iconLeft="bell" className="w-full sm:w-auto">
{t.notify}
</Button>
</div>
{error && <p id={`${id}-e`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{error}</p>}
</form>
);
}
function Drops(props: FlashSaleProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const left = useCountdown(props.endsIn);
const drops = props.drops ?? [];
const [active, setActive] = useState(Math.max(0, drops.findIndex((d) => d.status === 'live')));
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const base = useId();
const drop = drops[active];
const onKey = (e: React.KeyboardEvent, i: number) => {
const n = drops.length;
const go = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
if (go < 0) return;
e.preventDefault();
setActive(go);
tabs.current[go]?.focus();
};
if (!drop) return null;
const live = drop.status === 'live';
return (
<section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mb-8 max-w-[38rem] sm:mb-10">
{props.eyebrow && (
<p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-[color-mix(in_oklch,var(--destructive)_75%,var(--foreground))]">
<Icon name="bolt" size={15} />
{props.eyebrow}
</p>
)}
{props.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>}
{props.description && <p className="mt-3 text-pretty text-[16px] text-muted-foreground">{props.description}</p>}
</div>
<div role="tablist" aria-label={t.slots} className="grid grid-cols-3 gap-2 sm:flex">
{drops.map((d, i) => {
const on = i === active;
return (
<button
key={d.time}
ref={(el) => { tabs.current[i] = el; }}
role="tab"
id={`${base}-t${i}`}
aria-selected={on}
aria-controls={`${base}-p`}
tabIndex={on ? 0 : -1}
onClick={() => setActive(i)}
onKeyDown={(e) => onKey(e, i)}
className={cn('flex min-h-14 min-w-0 flex-col items-start justify-center gap-0.5 rounded-xl border px-3 py-2 text-left transition-[border-color,background-color] duration-300 sm:shrink-0 sm:flex-row sm:items-center sm:gap-3 sm:px-4', on ? 'border-foreground bg-foreground text-background' : 'border-border bg-card text-foreground hover:border-border-strong')}
>
<span className="text-[17px] font-semibold tabular-nums sm:text-[19px]">{formatTime(d.time, locale)}</span>
<span className={cn('flex min-w-0 items-center gap-1.5 truncate text-[11px] font-medium sm:text-[12px]', on ? 'text-background/75' : 'text-muted-foreground')}>
{d.status === 'live' && (
<span className="relative flex size-2">
<span className="absolute inset-0 animate-ping rounded-full bg-[var(--destructive)] opacity-70 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-[var(--destructive)]" />
</span>
)}
{d.label ?? (d.status === 'live' ? t.live : d.status === 'next' ? t.next : t.later)}
</span>
</button>
);
})}
</div>
<div key={active} id={`${base}-p`} role="tabpanel" aria-labelledby={`${base}-t${active}`} tabIndex={0} className="mt-6 animate-[kitshop-fade_0.35s_var(--ease-kit)] rounded-xl">
{live ? (
<>
{left > 0 && (
<div className="mb-5 flex flex-wrap items-center gap-3">
<span className="text-[13px] font-medium text-muted-foreground">{t.endsIn}</span>
<Clock left={left} t={t} size="sm" />
</div>
)}
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{drop.deals.map((d) => (
<li key={d.id} className="flex gap-4 rounded-2xl border border-border bg-card p-3 shadow-[var(--shadow-sm)]">
<div className="relative shrink-0">
<ProductMedia className="size-28 rounded-xl sm:size-32">{d.image}</ProductMedia>
<OffBadge deal={d} t={t} className="absolute left-1.5 top-1.5" />
</div>
<div className="flex min-w-0 flex-1 flex-col py-1">
<p className="text-[15px] font-medium text-foreground">{d.name}</p>
<PriceRow deal={d} currency={currency} />
<Claimed deal={d} t={t} className="mb-3 mt-2.5" />
<AddButton deal={d} t={t} onAdd={props.onAdd} className="mt-auto" />
</div>
</li>
))}
</ul>
</>
) : (
<div className="grid grid-cols-1 items-center gap-6 rounded-2xl border border-border bg-surface p-5 sm:p-7 lg:grid-cols-[minmax(0,1fr)_minmax(0,24rem)] lg:gap-10">
<div>
<p className="text-[13px] font-medium text-muted-foreground">{t.preview}</p>
<ul className="mt-3 grid grid-cols-3 gap-2 sm:gap-3" aria-label={t.preview}>
{drop.deals.slice(0, 3).map((d) => (
<li key={d.id}>
<div className="relative overflow-hidden rounded-xl">
<ProductMedia className="aspect-square rounded-xl blur-[6px] saturate-50">{d.image}</ProductMedia>
<span className="absolute inset-0 grid place-items-center">
<span className="grid size-10 place-items-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-md)]">
<Icon name="lock" size={16} />
</span>
</span>
</div>
<p className="mt-2 truncate text-[12px] font-medium text-foreground">{d.name}</p>
<p className="text-[12px] font-semibold text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]">{fill(t.off, { pct: formatPercent(pctOff(d), locale) })}</p>
</li>
))}
</ul>
</div>
<div>
<p className="text-h3 font-semibold text-foreground">{fill(t.startsAt, { time: formatTime(drop.time, locale) })}</p>
<p className="mb-5 mt-2 text-[14px] text-muted-foreground">{t.notifyText}</p>
<Notify time={drop.time} t={t} onNotify={props.onNotify} />
</div>
</div>
)}
</div>
</div>
</section>
);
}
/**
* A flash sale with a ticking clock: a dark banner, a grid of deals with a
* claimed bar each, one deal of the day with a limit per customer, or the
* drops of a day as tabs with a reminder for the next one.
*/
export function FlashSale(props: FlashSaleProps) {
const locale = useLocale();
const t = useTexts(TEXTS, props.labels);
const currency = currencyOf(locale, props.currency);
const { variant = 'deal-grid' } = props;
if (variant === 'banner') return <Banner {...props} t={t} />;
if (variant === 'deal-of-day') return <DealOfDay {...props} t={t} currency={currency} />;
if (variant === 'drops') return <Drops {...props} t={t} currency={currency} />;
return <DealGrid {...props} t={t} currency={currency} />;
}Frequent questions
How do I install Flash sale?
With aiw add @ai-website/flash-sale. The command puts components/ui/flash-sale.tsx into your project, the code is yours from then on. It brings along button, icon, product-image and input. It needs no further npm packages.
Which variants does Flash sale have?
4 variants, Countdown banner, Deal grid, Deal of the day and Timed drops. You pick one with the variant prop, for example variant="deal-grid".
Can I translate the texts of Flash sale?
Yes. Flash sale carries 31 texts of its own in English and German, such as “Ends in”, “This sale has ended” and “New deals start every day at 9:00.”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Flash sale?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Flash sale with aiw_components and install it with aiw_add @ai-website/flash-sale. Name the variant in your prompt, for example variant="banner", and the assistant writes your content straight into the props.