Skip to content
UI Kit
Accent color

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.

4 variantsOne of 2 components in Sales and promotions

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

Countdown bannerNew in 1.2

A dark band with drifting light, the countdown in large digits that roll on every second and one call to action. After the end it says when the next deals start.

Deal gridNew in 1.2

Four deals with the clock in the header. Each card has a claimed bar that fills on scroll, turns warm from 80 percent with the pieces left and greys out the card when sold out.

Deal of the dayNew in 1.2

One product large with a ring on its corner that empties with the time left. Price, saving, the ticking clock, the stock bar and a quantity capped at the limit per customer.

Timed dropsNew in 1.2

The time slots of a day as tabs with arrow keys, the live one with a pulsing dot. Its deals can be bought, later slots show a blurred first look and take an email for a reminder.

Details

What Flash sale brings along, read from the registry and the source.

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

Props

The FlashSaleProps interface from components/ui/flash-sale.tsx, with the comments from the code.

  • variantoptional
    FlashSaleVariant

    bannerdeal-griddeal-of-daydrops

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • endsInoptional
    number

    Seconds until the sale ends

  • dealsoptional
    FlashDeal[]
  • dropsoptional
    FlashDrop[]
  • limitoptional
    number

    Most pieces per customer (deal-of-day)

  • ctaoptional
    { label: string; href: string }
  • onAddoptional
    (deal: FlashDeal, quantity: number) => void
  • onNotifyoptional
    (email: string, time: string) => void
  • currencyoptional
    string

Built-in texts

Flash sale writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 31 texts
endsInEnglish Ends inGerman Endet in
endedEnglish This sale has endedGerman Diese Aktion ist vorbei
endedTextEnglish New deals start every day at 9:00.German Neue Angebote starten jeden Tag um 09:00.
daysEnglish DaysGerman Tage
hoursEnglish HoursGerman Std.
minutesEnglish MinGerman Min.
secondsEnglish SecGerman Sek.
claimedEnglish {pct} claimedGerman {pct} vergriffen
almostGoneEnglish Almost goneGerman Fast weg
soldOutEnglish Sold outGerman Ausverkauft
onlyLeftEnglish Only {n} leftGerman Nur noch {n}
addEnglish Add to cartGerman In den Warenkorb
addedEnglish AddedGerman Im Warenkorb
addNamedEnglish Add {name} to cartGerman {name} in den Warenkorb
saveEnglish Save {amount}German Spare {amount}
offEnglish −{pct}German −{pct}
limitEnglish Up to {n} per customerGerman Bis zu {n} pro Person
quantityEnglish QuantityGerman Menge
liveEnglish Live nowGerman Jetzt live
nextEnglish NextGerman Als Nächstes
laterEnglish LaterGerman Später
startsAtEnglish Starts at {time}German Startet um {time}
notifyTitleEnglish Get a reminderGerman Erinnerung bekommen
notifyTextEnglish We send one email when this drop goes live. Nothing else.German Wir schicken eine E-Mail, wenn dieser Drop live geht. Sonst nichts.
emailEnglish Email addressGerman E-Mail-Adresse
notifyEnglish Remind meGerman Erinnere mich
notifiedEnglish Done. We write to {email} at {time}.German Erledigt. Wir schreiben dir um {time} an {email}.
eEmptyEnglish Enter your email address for the reminder.German Gib deine E-Mail-Adresse für die Erinnerung ein.
eEmailEnglish This email address looks incomplete. Check the part after the @.German Diese E-Mail-Adresse wirkt unvollständig. Prüf den Teil nach dem @.
previewEnglish A first lookGerman Ein erster Blick
slotsEnglish Drops todayGerman 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
components/ui/flash-sale.tsx
'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.

Often searched

Jump to

Copied to the clipboard