Skip to content
All Taste components

Animated screens

Onboarding carousel

Three first-run slides that advance on their own, with a parallax illustration and step dots that stretch into a pill.

3 slides · auto-advance · parallax · morphing dots

Wayfarer

Your trip, even offline

Maps, tickets and bookings stay on your phone when the signal drops.

Already have an account? Sign in

// Onboarding carousel: slides auto-advance (and swipe), the backdrop moves slower than the art (parallax),
// the step dots stretch into a pill. Mobile-first page on the Taste preset; maps to React Native via NativeWind.
"use client";

import { useEffect, useRef, useState } from "react";
import { MapPin } from "lucide-react";
import { cn } from "@/lib/utils";

const slides = [
  { title: "Plan trips together", body: "Share one itinerary with friends and vote on stays, stops and dinner spots.", art: <MapArt /> },
  { title: "Book in one tap", body: "Flights, trains and stays in one checkout, with tickets saved to your trip.", art: <TicketArt /> },
  { title: "Your trip, even offline", body: "Maps, tickets and bookings stay on your phone when the signal drops.", art: <OfflineArt /> },
];

export default function Onboarding({ onDone }: { onDone?: () => void }) {
  const [step, setStep] = useState(0);
  const [paused, setPaused] = useState(false);
  const startX = useRef<number | null>(null);
  const last = step === slides.length - 1;

  // Auto-advance every 3.5 s until the last slide, or until the user takes over.
  useEffect(() => {
    if (paused || last) return;
    const t = setTimeout(() => setStep((s) => s + 1), 3500);
    return () => clearTimeout(t);
  }, [step, paused, last]);

  const go = (n: number) => {
    setPaused(true);
    setStep(Math.max(0, Math.min(slides.length - 1, n)));
  };

  return (
    <main className="mx-auto flex min-h-dvh max-w-md flex-col bg-bg px-4 pb-5 font-sans text-fg">
      <header className="flex h-14 items-center justify-between">
        <span className="flex items-center gap-2 text-[15px] font-semibold">
          <span className="grid size-[26px] place-items-center rounded-sm bg-primary text-primary-fg"><MapPin className="size-[15px]" /></span>
          Wayfarer
        </span>
        <button type="button" onClick={onDone} className="-mr-2 h-10 rounded px-2 text-sm font-medium text-fg-muted hover:bg-surface-2">Skip</button>
      </header>

      <div
        className="relative mt-1 min-h-56 flex-1 touch-pan-y overflow-hidden rounded-xl bg-primary-soft"
        onPointerDown={(e) => (startX.current = e.clientX)}
        onPointerUp={(e) => {
          if (startX.current === null) return;
          const dx = e.clientX - startX.current;
          startX.current = null;
          if (Math.abs(dx) > 40) go(step + (dx < 0 ? 1 : -1));
        }}
      >
        {/* Backdrop: a repeating pattern that moves a third as far as the slides */}
        <div
          aria-hidden
          className="absolute inset-0 transition-[background-position] duration-700 ease-taste motion-reduce:transition-none"
          style={{
            backgroundImage:
              "radial-gradient(circle at 40px 56px, color-mix(in srgb, var(--tst-primary) 16%, transparent) 0 26px, transparent 27px), radial-gradient(circle, color-mix(in srgb, var(--tst-primary) 24%, transparent) 1.2px, transparent 1.8px)",
            backgroundSize: "180px 100%, 20px 20px",
            backgroundPosition: -step * 60 + "px 0, " + -step * 60 + "px 0",
          }}
        />
        <div className="relative flex h-full transition-transform duration-700 ease-taste motion-reduce:transition-none" style={{ transform: "translateX(" + -step * 100 + "%)" }}>
          {slides.map((s, i) => (
            <div key={s.title} aria-hidden={i !== step} className="grid w-full shrink-0 place-items-center p-2">{s.art}</div>
          ))}
        </div>
      </div>

      <div className="mt-5 overflow-hidden">
        <div className="flex transition-transform duration-700 ease-taste motion-reduce:transition-none" style={{ transform: "translateX(" + -step * 100 + "%)" }}>
          {slides.map((s, i) => (
            <section key={s.title} aria-hidden={i !== step} className="w-full shrink-0">
              <h1 className="text-[22px] font-semibold leading-7 tracking-tight">{s.title}</h1>
              <p className="mt-2 text-sm text-fg-muted">{s.body}</p>
            </section>
          ))}
        </div>
      </div>

      <div className="my-4 flex gap-1.5">
        {slides.map((s, i) => (
          <button
            key={s.title}
            type="button"
            aria-label={"Go to step " + (i + 1)}
            aria-current={i === step ? "step" : undefined}
            onClick={() => go(i)}
            className={cn("h-1.5 rounded-full transition-all duration-500 ease-taste motion-reduce:transition-none", i === step ? "w-[22px] bg-primary" : "w-1.5 bg-border-strong")}
          />
        ))}
      </div>

      <button type="button" onClick={() => (last ? onDone?.() : go(step + 1))} className="h-12 w-full rounded bg-primary text-[15px] font-semibold text-primary-fg hover:bg-primary-hover">
        <span key={String(last)} className="inline-block animate-rise motion-reduce:animate-none">{last ? "Get started" : "Next"}</span>
      </button>
      <p className="mt-3 text-center text-[13px] text-fg-muted">Already have an account? <a href="/sign-in" className="font-medium text-primary">Sign in</a></p>
    </main>
  );
}

function MapArt() {
  return (
    <svg viewBox="0 0 252 236" className="h-full max-h-60 w-full" aria-hidden>
      <rect x="40" y="34" width="172" height="150" rx="16" className="fill-bg" />
      <path d="M40 84h172M40 134h172M97 34v150M155 34v150" className="stroke-border" strokeWidth="1.5" />
      <path d="M70 156c26-50 70-6 96-46 10-16 12-28 16-44" fill="none" className="stroke-primary" strokeWidth="3.5" strokeLinecap="round" strokeDasharray="2 8" />
      <circle cx="70" cy="156" r="7" className="fill-bg stroke-primary" strokeWidth="3.5" />
      <path d="M182 72c-9-10-14-17-14-24a14 14 0 0 1 28 0c0 7-5 14-14 24z" className="fill-primary" />
      <circle cx="182" cy="48" r="5" className="fill-bg" />
      <rect x="130" y="176" width="92" height="36" rx="18" className="fill-bg" />
      <circle cx="150" cy="194" r="11" className="fill-[var(--tst-warning-300)] stroke-bg" strokeWidth="3" />
      <circle cx="166" cy="194" r="11" className="fill-[var(--tst-success-300)] stroke-bg" strokeWidth="3" />
      <circle cx="182" cy="194" r="11" className="fill-brand-300 stroke-bg" strokeWidth="3" />
      <text x="198" y="198" className="fill-fg text-[11px] font-semibold">+2</text>
    </svg>
  );
}

function TicketArt() {
  const bars = [0, 6, 10, 16, 19, 25, 31, 34, 40, 46, 49, 55, 61, 64, 70, 76, 79, 85, 91, 96, 100, 106, 112];
  return (
    <svg viewBox="0 0 252 236" className="h-full max-h-60 w-full" aria-hidden>
      <path d="M50 52a14 14 0 0 1 14-14h124a14 14 0 0 1 14 14v68a11 11 0 0 0 0 22v38a14 14 0 0 1-14 14H64a14 14 0 0 1-14-14v-38a11 11 0 0 0 0-22z" className="fill-bg" />
      <path d="M64 131h124" className="stroke-border-strong" strokeWidth="1.5" strokeDasharray="4 5" />
      <text x="68" y="84" className="fill-fg text-2xl font-bold">BLR</text>
      <text x="68" y="102" className="fill-fg-muted text-[10px] font-medium">Bengaluru</text>
      <text x="184" y="84" textAnchor="end" className="fill-fg text-2xl font-bold">GOI</text>
      <text x="184" y="102" textAnchor="end" className="fill-fg-muted text-[10px] font-medium">Goa</text>
      <path d="M118 78h16m-6-6 6 6-6 6" fill="none" className="stroke-primary" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
      {bars.map((x, i) => <rect key={x} x={70 + x} y="148" width={i % 3 === 0 ? 3 : 2} height="22" className="fill-fg" />)}
      <circle cx="200" cy="42" r="18" className="fill-success stroke-bg" strokeWidth="4" />
      <path d="m192 42 6 6 10-11" fill="none" className="stroke-bg" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function OfflineArt() {
  return (
    <svg viewBox="0 0 252 236" className="h-full max-h-60 w-full" aria-hidden>
      <rect x="84" y="24" width="88" height="180" rx="16" className="fill-bg stroke-fg" strokeWidth="4" />
      <rect x="114" y="32" width="28" height="6" rx="3" className="fill-fg" />
      <path d="M92 70c22 6 30 26 52 22s22 22 24 30M92 140c18-14 40-4 52 10s20 10 24 6" fill="none" className="stroke-border-strong" strokeWidth="2" />
      <path d="M104 160c10-24 30-30 30-52 0-12 10-20 20-30" fill="none" className="stroke-primary" strokeWidth="3.5" strokeLinecap="round" />
      <circle cx="104" cy="160" r="6" className="fill-primary" />
      <circle cx="154" cy="78" r="6" className="fill-bg stroke-primary" strokeWidth="3.5" />
      <path d="M42 98a16 16 0 0 1 31-6 12 12 0 1 1 3 24H45a13 13 0 0 1-3-18z" className="fill-bg" />
      <path d="m48 88 26 30" className="stroke-fg-muted" strokeWidth="2.5" strokeLinecap="round" />
      <circle cx="180" cy="176" r="20" className="fill-primary stroke-bg" strokeWidth="4" />
      <path d="M180 166v18m-7-7 7 7 7-7" fill="none" className="stroke-primary-fg" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

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