# Waitlist 1

> A single-screen waitlist page that returns a position in line

A shadcn/ui block from the [@shadcnstore](https://www.shadcnstore.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnstore/waitlist-1).

- Page, with a live preview: https://shadcn.exchange/r/shadcnstore/waitlist-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnstore/waitlist-1-light-debadbb66b226f58.png
- Registry item: https://shadcnstore.com/r/waitlist-1.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/shadcnstore/waitlist-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `@shadcnstore/motion-avatar-group`, `avatar`, `badge`, `field`, `input-group`, `spinner`

## Files

### `storage/app/registry-sources/marketing/waitlist/waitlist-1/page.tsx`

```tsx
import { Waitlist1 } from "@/components/blocks/marketing/waitlist/waitlist-1/components/waitlist-1"

export default function Page() {
  return (
    <div className="flex min-h-svh w-full items-center justify-center">
      <div className="w-full">
        <Waitlist1 />
      </div>
    </div>
  )
}
```

### `storage/app/registry-sources/marketing/waitlist/waitlist-1/waitlist-1.tsx`

```tsx
'use client'

import { useId, useState, type FormEvent } from 'react'
import { motion, useReducedMotion } from 'motion/react'
import { Check } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
import { Spinner } from '@/components/ui/spinner'
import { Waitlist1QueueField } from './waitlist-1-queue-field'
import { Waitlist1WaitSteps } from './waitlist-1-wait-steps'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { AvatarGroup } from '@/components/motion/avatar-group'
import { socialLinks, waitlistAvatars, waitlistCopy } from "@/components/data/waitlist-1-data"

/**
 * Integration seam — resolve with the position the server assigned. The demo number is
 * generated client-side after submit, so it never differs between server and client render.
 */
async function join(email: string): Promise<number> {
  await new Promise(resolve => setTimeout(resolve, 800))
  void email
  return 400 + Math.floor(Math.random() * 60)
}

const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim())

type Status = 'idle' | 'submitting' | 'success'

/**
 * The ShadcnStore mark, per BLOCK-QUALITY-BAR.md §5.
 *
 * Every block that renders a first-party brand mark renders ours. The demo *content* stays the
 * industry business this batch is written around; the brand *chrome* is always ShadcnStore,
 * because every preview in the gallery is branded as ours and a buyer swaps one component after
 * pasting. Path data is the canonical mark from `resources/views/components/logo.blade.php`,
 * `currentColor` throughout so it re-themes, and `aria-hidden` because the wordmark beside it is
 * the accessible name.
 */
function Logo() {
  return (
    <span className="flex items-center justify-center gap-2">
      <svg viewBox="0 0 32 32" aria-hidden="true" className="text-primary size-6" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M26 24.75C26.4142 24.75 26.75 24.4142 26.75 24C26.75 23.5858 26.4142 23.25 26 23.25V24.75ZM26 23.25H11V24.75H26V23.25ZM8.75 21V15H7.25V21H8.75ZM11 23.25C9.75736 23.25 8.75 22.2426 8.75 21H7.25C7.25 23.0711 8.92893 24.75 11 24.75V23.25Z" fill="currentColor" />
        <path d="M1.5 3.25C1.08579 3.25 0.75 3.58579 0.75 4C0.75 4.41421 1.08579 4.75 1.5 4.75V3.25ZM1.5 4.75H6V3.25H1.5V4.75ZM7.25 6V21H8.75V6H7.25ZM6 4.75C6.69036 4.75 7.25 5.30964 7.25 6H8.75C8.75 4.48122 7.51878 3.25 6 3.25V4.75Z" fill="currentColor" />
        <path d="M22 21.75C22.4142 21.75 22.75 21.4142 22.75 21C22.75 20.5858 22.4142 20.25 22 20.25V21.75ZM22 20.25H11V21.75H22V20.25ZM8.75 18V12H7.25V18H8.75ZM11 20.25C9.75736 20.25 8.75 19.2426 8.75 18H7.25C7.25 20.0711 8.92893 21.75 11 21.75V20.25Z" fill="currentColor" />
        <path d="M27.2057 19.754C27.0654 20.1438 26.6357 20.346 26.246 20.2057C25.8562 20.0654 25.654 19.6357 25.7943 19.246L27.2057 19.754ZM30.0361 9.67744L29.3305 9.4234L29.3305 9.4234L30.0361 9.67744ZM25.7943 19.246L29.3305 9.4234L30.7418 9.93148L27.2057 19.754L25.7943 19.246ZM28.1543 7.75L8 7.75V6.25L28.1543 6.25V7.75ZM29.3305 9.4234C29.6237 8.60882 29.0201 7.75 28.1543 7.75V6.25C30.059 6.25 31.3869 8.13941 30.7418 9.93148L29.3305 9.4234Z" fill="currentColor" />
        <path d="M13.5 21.75C13.0858 21.75 12.75 21.4142 12.75 21C12.75 20.5858 13.0858 20.25 13.5 20.25V21.75ZM26.7111 19.009L27.4174 19.2613L27.4174 19.2613L26.7111 19.009ZM13.5 20.25H23.8858V21.75H13.5V20.25ZM26.0048 18.7568L27.7937 13.7477L29.2063 14.2523L27.4174 19.2613L26.0048 18.7568ZM23.8858 20.25C24.8367 20.25 25.6849 19.6522 26.0048 18.7568L27.4174 19.2613C26.8843 20.7537 25.4706 21.75 23.8858 21.75V20.25Z" fill="currentColor" />
        <path d="M21.1694 10.5806L14.5651 17.1849" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
        <path d="M22.1694 14.5806L18.5632 18.1868" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
        <circle cx="13.1" cy="26.1" r="1.7" stroke="currentColor" strokeWidth="1.2" />
        <circle cx="22.1" cy="26.1" r="1.7" stroke="currentColor" strokeWidth="1.2" />
      </svg>
      <span className="text-base font-semibold tracking-tight">ShadcnStore</span>
    </span>
  )
}

export function Waitlist1() {
  const inputId = useId()
  const errorId = useId()
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState<Status>('idle')
  const [error, setError] = useState<string | null>(null)
  const [position, setPosition] = useState<number | null>(null)
  const reduceMotion = useReducedMotion()

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()

    if (!isValidEmail(email)) {
      setError(email.trim() ? 'Enter a valid email address.' : 'Enter your email address.')
      return
    }

    setError(null)
    setStatus('submitting')
    setPosition(await join(email))
    setStatus('success')
  }

  // One load-in stagger: on a page-level block, arriving is the entrance.
  const rise = (index: number) =>
    reduceMotion
      ? {}
      : {
          initial: { opacity: 0, y: 12 },
          animate: { opacity: 1, y: 0 },
          transition: { duration: 0.5, ease: 'easeOut' as const, delay: index * 0.06 },
        }

  /*
    A rem floor, not `min-h-svh`. This is a full-page block so it wants to fill the fold, but
    `svh` resolves against whatever viewport it is in — and inside a container sized to its own
    content, which the gallery's preview frame is, that is circular: the block asks for the
    height of a box whose height it is setting. It settled at the frame's starting height and
    never came down, leaving several hundred pixels of empty page under the form.
  */
  return (
    <div className='from-primary/5 relative isolate grid min-h-[38rem] w-full place-items-center overflow-hidden bg-gradient-to-b to-transparent px-4 py-16'>
      <Waitlist1QueueField />

      {/*
        `max-w-3xl`, not `max-w-md`.

        A 48px headline inside a 448px column broke into four lines of two or three words each,
        which is the readability problem: at that measure the eye gets no run of text to travel
        along and every line is a fresh start. The wider column also gives the three steps under
        the form a row of their own. The headline, the lede and the form each keep a narrower
        measure inside it — a single input wants to look like one field, not a banner.
      */}
      <main className='relative w-full max-w-3xl text-center'>
        <motion.p {...rise(0)} className='flex justify-center'>
          <Logo />
        </motion.p>

        <motion.div {...rise(1)} className='mt-6'>
          <Badge variant='outline' className='border-foreground/20 rounded-full'>
            <span
              aria-hidden='true'
              className='bg-primary mr-1.5 size-1.5 rounded-full motion-safe:animate-pulse'
            />
            {waitlistCopy.badge}
          </Badge>
        </motion.div>

        {/*
          `leading-[1.08]` and `text-balance`: at display sizes the default line height leaves
          the lines drifting apart, and balancing stops the last line becoming a single orphaned
          word.
        */}
        <motion.h1
          {...rise(2)}
          className='mx-auto mt-5 max-w-2xl text-4xl leading-[1.08] font-semibold tracking-tight text-balance sm:text-5xl'
        >
          {waitlistCopy.headline}
        </motion.h1>

        {/*
          `text-foreground/75` rather than `text-muted-foreground`. The muted token is mixed for
          small print on a solid surface; on this washed gradient it fell close enough to the
          background that the one sentence explaining the product was the hardest thing on the
          page to read. Larger, and darker, since it is doing real work.
        */}
        <motion.p {...rise(3)} className='text-foreground/75 mx-auto mt-4 max-w-md text-lg text-pretty'>
          {waitlistCopy.subhead}
        </motion.p>

        <motion.div {...rise(4)} className='mx-auto mt-8 max-w-md'>
          {status === 'success' ? (
            /*
              The position is the receipt. It is set in the display size the headline uses, in
              tabular figures, because the number is the whole reason someone gave an address —
              rendering it in body text beside a tick reads as a form that submitted, not as a
              place in a line.
            */
            <div
              role='status'
              className='border-foreground/12 bg-card motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 motion-safe:duration-200 flex flex-col items-center gap-1 rounded-xl border p-6 shadow-sm'
            >
              <span className='text-muted-foreground flex items-center gap-1.5 font-mono text-xs tracking-[0.18em] uppercase'>
                <Check className='text-primary size-3.5' aria-hidden='true' />
                {waitlistCopy.successTitle}
              </span>
              <p className='mt-1 text-5xl font-semibold tracking-tight tabular-nums'>
                <span className='text-muted-foreground/60'>#</span>
                {position}
              </p>
              <p className='text-muted-foreground mt-2 text-sm text-pretty'>
                {waitlistCopy.successBody}
              </p>
            </div>
          ) : (
            <form onSubmit={handleSubmit} noValidate>
              {/*
                `Field` rather than a bare label above a control: it is what carries the invalid
                state to everything inside it, so the field, its label and its message stay in
                one piece instead of each being told separately.
              */}
              <Field data-invalid={error ? true : undefined}>
                <FieldLabel htmlFor={inputId} className='sr-only'>
                  Email address
                </FieldLabel>
                {/*
                  One pill carrying the field and its button. Side by side they were two controls
                  for one action, and the pair either wrapped or squeezed the input at small
                  widths; inside a single group there is nothing to wrap.
                */}
                <InputGroup className='border-foreground/20 h-12 rounded-full pl-3'>
                  <InputGroupInput
                    id={inputId}
                    type='email'
                    inputMode='email'
                    autoComplete='email'
                    placeholder='you@email.com'
                    value={email}
                    onChange={event => setEmail(event.target.value)}
                    aria-invalid={error ? true : undefined}
                    aria-describedby={error ? errorId : undefined}
                    disabled={status === 'submitting'}
                  />
                  <InputGroupAddon align='inline-end'>
                    <InputGroupButton
                      type='submit'
                      variant='default'
                      size='sm'
                      className='h-9 rounded-full px-4'
                      disabled={status === 'submitting'}
                      aria-busy={status === 'submitting'}
                    >
                      {status === 'submitting' ? (
                        <>
                          <Spinner data-icon='inline-start' />
                          Joining…
                        </>
                      ) : (
                        waitlistCopy.submitLabel
                      )}
                    </InputGroupButton>
                  </InputGroupAddon>
                  </InputGroup>
                {error ? (
                  <FieldError id={errorId} role='alert' className='text-left'>
                    {error}
                  </FieldError>
                ) : null}
              </Field>
            </form>
          )}
        </motion.div>

        {/*
          The social proof line retires once there is a position. "2,431 people ahead of you"
          under a card that has just said "#402" contradicts it — the number that matters after
          joining is the reader's own.
        */}
        <motion.div
          {...rise(5)}
          className={`mt-8 flex items-center justify-center gap-3 ${status === 'success' ? 'hidden' : ''}`}
        >
          <AvatarGroup aria-hidden='true'>
            {waitlistAvatars.map(src => (
              <Avatar key={src} className='ring-background size-7 ring-2'>
                <AvatarImage src={src} alt='' />
                <AvatarFallback />
              </Avatar>
            ))}
          </AvatarGroup>
          <p className='text-muted-foreground text-sm'>{waitlistCopy.proof}</p>
        </motion.div>

        <Waitlist1WaitSteps rise={rise} />

        <motion.ul {...rise(8)} className='mt-12 flex flex-wrap items-center justify-center gap-x-6 gap-y-2'>
          {socialLinks.map(link => (
            <li key={link.label}>
              <a
                href={link.href}
                aria-label={`${waitlistCopy.brand} on ${link.label}`}
                className='text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm text-sm transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none'
              >
                {link.label}
              </a>
            </li>
          ))}
        </motion.ul>
      </main>
    </div>
  )
}

export default Waitlist1
```

### `storage/app/registry-sources/marketing/waitlist/waitlist-1/waitlist-1-queue-field.tsx`

```tsx
'use client'

import { motion, useReducedMotion } from 'motion/react'

/**
 * A queue, drifting forward behind the copy.
 *
 * The section had no image and no motion, so it was a headline on a white page and read as a
 * placeholder however good the words were. The subject supplies the motif: a waitlist is a line
 * of people moving, so the backdrop is rows of short ticks travelling steadily to the right, with
 * one tick per row lit to stand for a place in it.
 *
 * Every value is derived from `--foreground` and `--primary`, so the field re-themes with the
 * customizer and needs no `dark:` override.
 */

/** One tick plus its gap. The row animates exactly this far, so the loop is seamless at any width. */
const tickPitch = 26
const rowHeight = 34

/**
 * Rows drift at slightly different speeds, so the field never resolves into a single moving grid.
 *
 * They sit at the top and bottom edges rather than spread evenly down the plane. Spread out, a
 * row crossing the middle reads as a rule struck through the headline rather than as texture
 * behind it — the mask alone was not enough, because a straight line running the full width is
 * legible at almost any opacity.
 */
const rows = [
  { top: '6%', seconds: 15, litAt: 3 },
  { top: '17%', seconds: 19, litAt: 7 },
  { top: '76%', seconds: 13, litAt: 1 },
  { top: '87%', seconds: 21, litAt: 5 },
]

const tickColor = 'color-mix(in oklab, var(--foreground) 7%, transparent)'

/*
  Masked on both axes so the field has no hard edge, and cleared hardest through the middle where
  the headline sits — the ticks are atmosphere, and atmosphere that competes with the type it sits
  behind has stopped being atmosphere.
*/
const fadeVertical = 'linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)'
const fadeHorizontal = 'linear-gradient(to right, transparent 0%, #000 18%, #000 82%, transparent 100%)'
const clearing = 'radial-gradient(ellipse 64% 62% at 50% 50%, transparent 45%, #000 100%)'

function Row({ top, seconds, litAt, reduceMotion }: { top: string; seconds: number; litAt: number; reduceMotion: boolean }) {
  return (
    <div className="absolute inset-x-0" style={{ top, height: rowHeight }}>
      <motion.div
        className="absolute -inset-x-full flex items-center"
        style={{ gap: `${tickPitch - 10}px` }}
        animate={reduceMotion ? undefined : { x: [0, tickPitch] }}
        transition={{ duration: seconds, ease: 'linear', repeat: Infinity }}
      >
        {Array.from({ length: 160 }, (_, index) => (
          <span
            key={index}
            className="h-1 w-2.5 shrink-0 rounded-full"
            style={{
              // One lit tick every twelve, offset per row: a place in the line rather than a pattern.
              backgroundColor: index % 12 === litAt ? 'var(--primary)' : tickColor,
              opacity: index % 12 === litAt ? 0.4 : 1,
            }}
          />
        ))}
      </motion.div>
    </div>
  )
}

export function Waitlist1QueueField() {
  const reduceMotion = useReducedMotion()

  return (
    <div
      aria-hidden="true"
      className="pointer-events-none absolute inset-0 overflow-hidden"
      style={{ maskImage: fadeVertical, WebkitMaskImage: fadeVertical }}
    >
      <div className="absolute inset-0" style={{ maskImage: fadeHorizontal, WebkitMaskImage: fadeHorizontal }}>
        <div className="absolute inset-0" style={{ maskImage: clearing, WebkitMaskImage: clearing }}>
          {rows.map(row => (
            <Row key={row.top} {...row} reduceMotion={Boolean(reduceMotion)} />
          ))}
        </div>
      </div>
    </div>
  )
}

export default Waitlist1QueueField
```

### `storage/app/registry-sources/marketing/waitlist/waitlist-1/waitlist-1-wait-steps.tsx`

```tsx
'use client'

import { motion } from 'motion/react'
import { waitSteps } from "@/components/data/waitlist-1-data"

/**
 * What the wait actually involves, in three steps.
 *
 * The block used to end at the email field with half a screen of nothing under it, which is the
 * shape of every waitlist page that never converted: it asks for an address and says nothing
 * about what happens next. Naming the cadence — a wave a week, forty places, three days' notice —
 * is the argument for joining, and it is also what fills the fold.
 *
 * Numbered rather than iconed. Three lucide glyphs above three lines is the stock feature grid;
 * a step number says these things happen in an order, which is the only claim being made.
 */
export function Waitlist1WaitSteps({ rise }: { rise: (index: number) => Record<string, unknown> }) {
  return (
    <motion.section
      {...rise(7)}
      aria-labelledby="waitlist-1-steps"
      className="border-foreground/12 mt-16 w-full border-t pt-10"
    >
      <h2
        id="waitlist-1-steps"
        className="text-muted-foreground text-center font-mono text-xs tracking-[0.2em] uppercase"
      >
        What happens after you join
      </h2>

      <ol className="mt-8 grid gap-8 text-left sm:grid-cols-3 sm:gap-10">
        {waitSteps.map(step => (
          <li key={step.step} className="flex flex-col gap-2">
            <span className="text-muted-foreground font-mono text-xs tracking-[0.2em]">{step.step}</span>
            <p className="text-sm font-medium">{step.title}</p>
            <p className="text-muted-foreground text-sm leading-6 text-pretty">{step.detail}</p>
          </li>
        ))}
      </ol>
    </motion.section>
  )
}

export default Waitlist1WaitSteps
```

### `storage/app/registry-sources/marketing/waitlist/waitlist-1/waitlist-1-data.ts`

```ts
/** Synthetic Meridian Health (telehealth & multi-site clinic platform) demo data. */

export interface SocialLink {
  label: string
  href: string
}

/** Photographic avatars keep the stack legible on both themes without a fixed backing colour. */
export const waitlistAvatars: string[] = [
  'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/sarah-johnson.400w.01a6ba.avif',
  'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/michael-rodriguez.400w.d8234b.avif',
  'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/emily-chen.400w.8fb3cf.avif',
  'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/david-kim.400w.a3611d.avif',
]

export const waitlistCopy = {
  /*
    Brand chrome, so it is ours: the social accounts a waitlist page links to belong to whoever
    is running the waitlist, and every preview in the gallery is branded as ShadcnStore. The rest
    of the copy stays the industry business the batch is written around.
  */
  brand: 'ShadcnStore',
  badge: 'Opening in March · Alvalade',
  /*
    A clinic list, not "the workflow engine your ops team keeps asking for". The old dressing was
    the SaaS default the quality bar bans by name — schedules, retries, audit trails — and it gave
    the block nothing specific to say. A clinic that can only register forty patients a week has a
    real reason to hold a list, and every number on the page can be checked against that fact.
  */
  headline: 'Meridian Alvalade opens its list in March.',
  subhead:
    'One clinician who knows your history, same-week appointments, and no call centre. We register forty new patients a week.',
  proof: '2,431 people ahead of you',
  submitLabel: 'Join the list',
  successTitle: 'You are on the list',
  successBody: 'We email each wave three days before it opens, with a slot already held for you.',
}

/**
 * What happens after the email is given, in three steps.
 *
 * A waitlist asks for something and promises nothing in return, which is why so many of them are
 * a headline and a box. Naming the wait — how long, what arrives, what is decided — is the part
 * that makes it worth joining, and it fills the half-page the form used to leave empty.
 */
export type WaitStep = { step: string; title: string; detail: string }

export const waitSteps: WaitStep[] = [
  {
    step: '01',
    title: 'You take a place in line',
    detail: 'Position by the second the form is submitted. No queue-jumping, no referral tricks.',
  },
  {
    step: '02',
    title: 'A wave opens every Monday',
    detail: 'Forty places. You are emailed three days before yours, so nothing needs watching.',
  },
  {
    step: '03',
    title: 'You pick the clinician',
    detail: 'Six GPs at Alvalade, with their languages and next free slot listed. Change once, free.',
  },
]

export const socialLinks: SocialLink[] = [
  { label: 'Instagram', href: '#instagram' },
  { label: 'The clinic', href: '#clinic' },
  { label: 'Careers', href: '#careers' },
]
```
