Skip to content
All Taste components

Animated screens

Checkout stepper

A four-step checkout where the progress line moves from cart to address to payment to done.

4 steps · springing progress line · cards swap · done check

Checkout
  1. 1Cart
  2. 2Address
  3. 3Pay
  4. 4Done
Linen throw cushion

Sage · 45 × 45 cm

₹899
Ceramic table lamp

Off-white · warm bulb

₹1,299
Subtotal
₹2,198
Festive offer
−₹352
Delivery
Free

Deliver to

Home

Flat 4B, Lakeview Residency, 12th Main, Indiranagar, Bengaluru 560038

Work

Level 3, Prestige Atrium, Residency Rd, Bengaluru 560025

Arrives Sun, 18 Oct · Free delivery

Pay with

UPI

priya.r@okbank

Card

Visa ending 4421

Cash on delivery

₹20 handling fee

Order placed

#SX-20481 · Arrives Sun, 18 Oct

Paid ₹1,846 with UPI

Total

₹1,846
Track order
/* globals.css
@keyframes panel-in { from { opacity: 0; transform: translateX(28px); } }
@keyframes pop { from { opacity: 0; transform: scale(.5); } }
*/
"use client";

import { useState } from "react";
import { ArrowLeft, Banknote, Briefcase, CalendarDays, Check, CreditCard, House, Smartphone } from "lucide-react";
import { cn } from "@/lib/utils";

const steps = ["Cart", "Address", "Pay", "Done"];
const cta = ["Continue to address", "Continue to payment", "Pay ₹1,846", "Track order"];
const cart = [
  { name: "Linen throw cushion", meta: "Sage · 45 × 45 cm", price: "₹899" },
  { name: "Ceramic table lamp", meta: "Off-white · warm bulb", price: "₹1,299" },
];

function Option({ on, icon: Icon, title, note, onClick }: { on: boolean; icon: typeof House; title: string; note: string; onClick: () => void }) {
  return (
    <button type="button" role="radio" aria-checked={on} onClick={onClick}
      className={cn("mt-2 flex w-full gap-3 rounded-lg border p-3 text-left transition-colors", on ? "border-primary bg-primary-soft/50" : "border-border")}>
      <span className={cn("mt-0.5 size-[18px] shrink-0 rounded-full border-2", on ? "border-[5px] border-primary" : "border-border-strong")} />
      <span className="min-w-0"><span className="flex items-center gap-1.5 text-sm font-semibold"><Icon className="size-3.5" />{title}</span><span className="text-xs text-fg-muted">{note}</span></span>
    </button>
  );
}

export default function CheckoutScreen() {
  const [step, setStep] = useState(0);
  const [address, setAddress] = useState("home");
  const [pay, setPay] = useState("upi");

  return (
    <div className="mx-auto flex min-h-dvh max-w-md flex-col bg-bg font-sans text-fg">
      <header className="flex h-12 items-center px-2">
        <button type="button" aria-label="Back" disabled={step === 0 || step === 3} onClick={() => setStep(step - 1)} className="grid size-11 place-items-center rounded-full disabled:opacity-30"><ArrowLeft className="size-[22px]" /></button>
        <h1 className="flex-1 text-center text-[15px] font-semibold">Checkout</h1>
        <span className="w-11" />
      </header>

      <ol aria-label="Checkout progress" className="relative grid grid-cols-4 px-4 pb-3.5">
        <span aria-hidden className="absolute inset-x-[calc(12.5%+4px)] top-[13px] h-[3px] rounded-full bg-border">
          <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-700 ease-spring motion-reduce:transition-none" style={{ transform: "scaleX(" + step / 3 + ")" }} />
        </span>
        {steps.map((s, i) => {
          const done = i < step || step === 3;
          return (
            <li key={s} aria-current={i === step ? "step" : undefined} className={cn("relative grid justify-items-center gap-1 text-[11px] font-medium transition-colors duration-300", i <= step ? "text-fg" : "text-fg-subtle")}>
              <span className={cn("grid size-7 place-items-center rounded-full border-2 text-xs font-semibold transition-colors duration-300",
                done ? "border-primary bg-primary text-primary-fg" : i === step ? "border-primary bg-primary-soft text-primary-soft-fg" : "border-border-strong bg-bg text-fg-muted")}>
                {done ? <Check className="size-3.5 animate-[pop_.35s_cubic-bezier(.34,1.56,.64,1)_both]" /> : i + 1}
              </span>
              {s}
            </li>
          );
        })}
      </ol>

      {/* key remounts the panel so each step slides in. */}
      <main key={step} className="flex-1 px-4 animate-[panel-in_.45s_cubic-bezier(.2,.7,.1,1)_both] motion-reduce:animate-none">
        {step === 0 && (
          <>
            {cart.map((c) => (
              <div key={c.name} className="flex items-center gap-3 border-b border-border py-2.5">
                <span aria-hidden className="size-12 shrink-0 rounded bg-surface-2" />
                <div className="min-w-0 flex-1"><p className="text-sm font-semibold">{c.name}</p><p className="text-xs text-fg-muted">{c.meta}</p></div>
                <span className="text-sm font-semibold tabular-nums">{c.price}</span>
              </div>
            ))}
            <dl className="mt-3 grid gap-2 text-[13.5px] tabular-nums">
              <div className="flex justify-between"><dt className="text-fg-muted">Subtotal</dt><dd>₹2,198</dd></div>
              <div className="flex justify-between"><dt className="text-fg-muted">Festive offer</dt><dd className="text-success-fg">−₹352</dd></div>
              <div className="flex justify-between"><dt className="text-fg-muted">Delivery</dt><dd>Free</dd></div>
            </dl>
          </>
        )}
        {step === 1 && (
          <div role="radiogroup" aria-label="Deliver to">
            <p className="text-xs font-medium text-fg-muted">Deliver to</p>
            <Option on={address === "home"} onClick={() => setAddress("home")} icon={House} title="Home" note="Flat 4B, Lakeview Residency, 12th Main, Indiranagar, Bengaluru 560038" />
            <Option on={address === "work"} onClick={() => setAddress("work")} icon={Briefcase} title="Work" note="Level 3, Prestige Atrium, Residency Rd, Bengaluru 560025" />
            <p className="mt-3 flex items-center gap-1.5 text-xs text-success-fg"><CalendarDays className="size-3.5" />Arrives Sun, 18 Oct · Free delivery</p>
          </div>
        )}
        {step === 2 && (
          <div role="radiogroup" aria-label="Pay with">
            <p className="text-xs font-medium text-fg-muted">Pay with</p>
            <Option on={pay === "upi"} onClick={() => setPay("upi")} icon={Smartphone} title="UPI" note="priya.r@okbank" />
            <Option on={pay === "card"} onClick={() => setPay("card")} icon={CreditCard} title="Card" note="Visa ending 4421" />
            <Option on={pay === "cod"} onClick={() => setPay("cod")} icon={Banknote} title="Cash on delivery" note="₹20 handling fee" />
          </div>
        )}
        {step === 3 && (
          <div className="grid justify-items-center pt-6 text-center">
            <span className="grid size-16 place-items-center rounded-full bg-success-soft text-success-fg animate-[pop_.5s_cubic-bezier(.34,1.56,.64,1)_.15s_both] motion-reduce:animate-none"><Check className="size-8" /></span>
            <h2 className="mt-3 text-base font-semibold">Order placed</h2>
            <p className="text-xs tabular-nums text-fg-muted">#SX-20481 · Arrives Sun, 18 Oct</p>
          </div>
        )}
      </main>

      <footer className="flex items-center gap-3 border-t border-border px-4 pb-[max(env(safe-area-inset-bottom),16px)] pt-3">
        <div><p className="text-xs text-fg-muted">Total</p><p className="font-semibold tabular-nums">₹1,846</p></div>
        <button type="button" onClick={() => setStep(Math.min(step + 1, 3))} className="h-12 flex-1 rounded bg-primary text-[15px] font-semibold text-primary-fg hover:bg-primary-hover">{cta[step]}</button>
      </footer>
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.