nextui.dev
Home
Library
Docs
Pricing
Components26
AI & agents5
  • AI chat inputPro
  • Agent task checklistPro
  • Agent thinking
  • Thinking indicator
  • Prompt deckNew
Forms & inputs7
  • Input with label
  • Input with icon and button
  • Settings switches
  • Password with strength meter
  • One-time code input
  • WaitlistNew
  • File dropzonePro
Cards5
  • Style marquee cardPro
  • Folder card
  • Celebration cardPro
  • Smart PDF stackNew
  • Waitlist pass
Buttons, badges & avatars5
  • Buttons with icons
  • QR scan badge
  • Avatar group
  • Rolling price badge
  • Avatars with status
Feedback & display4
  • Alert banners
  • FAQ accordion
  • Keyboard shortcuts
  • Empty state
Blocks11
Hero4
  • Animated pill heroPro
  • Integrations grid hero
  • Waitlist heroNew
  • Orbit chat heroNew
Features & pricing3
  • Usage cardPro
  • Three-tier pricing
  • Feature with board
Testimonials & team3
  • Testimonial cards
  • Single quote
  • Team carousel
Sign-in1
  • Sign-in cardPro
Motion2
Effects2
  • Text revealPro
  • Sound ringNew
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Motion
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextUI

© 2026 NEXTUI.DEV. All rights reserved.

ComponentsForms & inputsWaitlist

WaitlistNew

Email signup with a live avatar stack: new people slide in, the last one tucks under a rolling join counter.

Copy prompt
MCP

Installation

Runs as is, no setup needed. Want a shorter command like @nextui/waitlist? See short names.

$pnpm dlx shadcn@latest add https://tinistudio.vercel.app/r/waitlist.json

Related

All components

Input with label

Email input with a label and helper text.

Input with icon and button

Newsletter-style input with a leading icon and submit button.

Settings switches

Notification settings list with switches.

npx shadcn@latest add https://tinistudio.vercel.app/r/waitlist.json
components/nextui/waitlist.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, MotionConfig, motion, useInView } from "motion/react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

type Person = { id: number; initials: string; photo?: string };

const PHOTOS = [12, 32, 47, 5, 68, 15, 44, 25, 59, 9, 36, 21];
const INITIALS = ["MT", "AC", "SL", "JN", "KP", "LH", "RD", "VB", "TN", "EO", "QM", "HP"];

const personAt = (id: number): Person => {
  const i = id % PHOTOS.length;
  return { id, initials: INITIALS[i], photo: `https://i.pravatar.cc/80?img=${PHOTOS[i]}` };
};

const VISIBLE = 5;
const EASE = [0.22, 1, 0.36, 1] as const;

/** One digit that rolls up to its new value: the old one leaves upward, the new one rises from below. */
function RollingDigit({ digit }: { digit: string }) {
  return (
    <span className="relative inline-flex h-[1em] w-[0.62em] justify-center overflow-hidden leading-none">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={digit}
          initial={{ y: "100%", opacity: 0, filter: "blur(2px)" }}
          animate={{ y: "0%", opacity: 1, filter: "blur(0px)" }}
          exit={{ y: "-100%", opacity: 0, filter: "blur(2px)" }}
          transition={{ duration: 0.45, ease: EASE, delay: 0.35 }}
        >
          {digit}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function RollingNumber({ value }: { value: number }) {
  const digits = String(value).split("");
  return (
    <span className="inline-flex tabular-nums" aria-live="polite" aria-label={String(value)}>
      {digits.map((d, i) => (
        // Key by place value so only the digits that change roll.
        <RollingDigit key={digits.length - i} digit={d} />
      ))}
    </span>
  );
}

export default function Waitlist() {
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { margin: "-40px" });
  const [count, setCount] = useState(180);
  const [people, setPeople] = useState<Person[]>(() =>
    Array.from({ length: VISIBLE }, (_, i) => personAt(VISIBLE - 1 - i)),
  );
  const [joined, setJoined] = useState(false);
  const nextId = useRef(VISIBLE);

  const addPerson = (person: Person) => {
    setPeople((list) => [person, ...list].slice(0, VISIBLE));
    setCount((c) => c + 1);
  };

  // While in view, someone new joins every couple of seconds.
  useEffect(() => {
    if (!inView) return;
    const timer = setInterval(() => {
      const person = personAt(nextId.current++);
      setPeople((list) => [person, ...list].slice(0, VISIBLE));
      setCount((c) => c + 1);
    }, 2200);
    return () => clearInterval(timer);
  }, [inView]);

  return (
    <MotionConfig reducedMotion="user">
      <div ref={ref} className="grid w-full max-w-md gap-4">
        <form
          className="flex gap-2"
          onSubmit={(e) => {
            e.preventDefault();
            if (joined) return;
            setJoined(true);
            addPerson({ id: nextId.current++, initials: "You" });
          }}
        >
          <Label htmlFor="waitlist-email" className="sr-only">
            Email
          </Label>
          <Input
            id="waitlist-email"
            type="email"
            required
            placeholder="Enter your email"
            disabled={joined}
            className="h-10 flex-1 border-transparent bg-muted shadow-none dark:bg-muted"
          />
          <Button type="submit" className="h-10 px-5" disabled={joined}>
            {joined ? "You're in" : "Join Waitlist"}
          </Button>
        </form>

        <div className="flex items-center gap-2">
          <div className="relative flex items-center pl-2">
            <AnimatePresence mode="popLayout" initial={false}>
              {people.map((p) => (
                <motion.div
                  key={p.id}
                  layout
                  initial={{ opacity: 0, scale: 0.6, x: -10, filter: "blur(6px)" }}
                  animate={{ opacity: 1, scale: 1, x: 0, filter: "blur(0px)" }}
                  exit={{ opacity: 0, scale: 0.8, x: 18, filter: "blur(2px)" }}
                  transition={{ duration: 0.55, ease: EASE }}
                  className="-ml-2"
                >
                  <Avatar className="size-7 ring-2 ring-background">
                    {p.photo && <AvatarImage src={p.photo} alt="" />}
                    <AvatarFallback className="text-[10px] font-medium">{p.initials}</AvatarFallback>
                  </Avatar>
                </motion.div>
              ))}
            </AnimatePresence>
          </div>

          {/* Sits above the stack so the leaving avatar slides in underneath it. */}
          <div className="relative z-10 flex items-center gap-1.5 rounded-full bg-muted py-0.5 pr-3 pl-0.5 text-xs">
            <span className="flex size-7 items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground ring-2 ring-background">
              <RollingNumber value={count} />
            </span>
            <span className="text-muted-foreground">People joined today</span>
          </div>
        </div>
      </div>
    </MotionConfig>
  );
}
Sign in