Clevision

Waitlist 01

Component 10

About

Responsive waitlist section by Shadcn Studio for modern websites.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Waitlist 01 to your project and fits it to your theme.

Code

How Waitlist 01 is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

'use client'

import { useState } from 'react'
import type { CSSProperties, FormEvent, ReactNode } from 'react'
import { ArrowRightIcon, CheckIcon } from 'lucide-react'

export type AvatarItem = {
  src: string
  name: string
  fallback: string
}

export type ThreadItem = {
  username: string
  date: string
  body: string
  image: string
}

export type WaitlistProps = {
  threads?: ThreadItem[]
  avatars?: AvatarItem[]
  brandName?: string
  threadIcon?: string
  headline?: string
  description?: string
  joinedCount?: string
  className?: string
  onSubmit?: (email: string) => void
}

const CDN = 'https://cdn.shadcnstudio.com/ss-assets'

const DEFAULT_THREADS: ThreadItem[] = [
  {
    username: 'milly',
    date: '03/08/2024',
    body: 'Easy to adopt, powerful in practice. It helped our team move faster without sacrificing quality.',
    image: `${CDN}/blocks/marketing/waitlist/image-05.png`
  },
  {
    username: 'carter',
    date: '23/04/2024',
    body: "This software streamlined my workflow and removed a lot of repetitive tasks. It's become part of my daily process.",
    image: `${CDN}/blocks/marketing/waitlist/image-06.png`
  },
  {
    username: 'mark',
    date: '12/06/2024',
    body: 'The level of precision and reliability is impressive. It feels like working with an intelligent assistant rather than just another tool.',
    image: `${CDN}/blocks/marketing/waitlist/image-07.png`
  },
  {
    username: 'daniel_wong',
    date: '06/09/2024',
    body: 'Thoughtfully built and incredibly smooth. Every detail shows care for the user experience.',
    image: `${CDN}/blocks/marketing/waitlist/image-08.png`
  },
  {
    username: 'sofia',
    date: '18/07/2024',
    body: 'Onboarding was seamless. The interface is intuitive and the results speak for themselves.',
    image: `${CDN}/blocks/marketing/waitlist/img-19.png`
  }
]

const DEFAULT_AVATARS: AvatarItem[] = [
  { src: `${CDN}/avatar/avatar-11.png`, name: 'Alice Monroe', fallback: 'AM' },
  { src: `${CDN}/avatar/avatar-12.png`, name: 'James Carter', fallback: 'JC' },
  { src: `${CDN}/avatar/avatar-13.png`, name: 'Sofia Patel', fallback: 'SP' },
  { src: `${CDN}/avatar/avatar-14.png`, name: 'Ryan Hughes', fallback: 'RH' }
]

const DEFAULT_THREAD_ICON = `${CDN}/brand-logo/thread-icon.png`

const cn = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ')

const focusRing =
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background'

const MARQUEE_CSS = `
@keyframes waitlist01-marquee-vertical {
  from { transform: translateY(0); }
  to { transform: translateY(calc(-100% - var(--marquee-gap))); }
}
.waitlist01-marquee-track {
  animation: waitlist01-marquee-vertical var(--marquee-duration) linear infinite;
  animation-delay: var(--marquee-delay);
}
.waitlist01-marquee-track[data-reverse='true'] { animation-direction: reverse; }
.waitlist01-marquee:hover .waitlist01-marquee-track,
.waitlist01-marquee:focus-within .waitlist01-marquee-track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .waitlist01-marquee-track { animation: none; }
}
`

type MarqueeProps = {
  children: ReactNode
  duration?: number
  gap?: number
  reverse?: boolean
  repeat?: number
  className?: string
}

function VerticalMarquee({ children, duration = 25, gap = 1.5, reverse = false, repeat = 4, className }: MarqueeProps) {
  const style = {
    '--marquee-duration': `${duration}s`,
    '--marquee-delay': '0s',
    '--marquee-gap': `${gap}rem`
  } as CSSProperties

  return (
    <div
      style={style}
      className={cn('waitlist01-marquee flex flex-col gap-(--marquee-gap) overflow-hidden p-3', className)}
    >
      {Array.from({ length: repeat }, (_, i) => (
        <div
          key={i}
          aria-hidden={i > 0}
          data-reverse={reverse}
          className='waitlist01-marquee-track flex shrink-0 flex-col justify-around gap-(--marquee-gap)'
        >
          {children}
        </div>
      ))}
    </div>
  )
}

function LogoMark({ className }: { className?: string }) {
  return (
    <svg viewBox='0 0 328 329' fill='none' aria-hidden='true' className={className}>
      <rect y='0.5' width='328' height='328' rx='164' className='fill-foreground' />
      <g className='stroke-background' strokeWidth='20'>
        <path d='M165.018 72.3008V132.771C165.018 152.653 148.9 168.771 129.018 168.771H70.2288' />
        <path d='M166.627 265.241L166.627 204.771C166.627 184.889 182.744 168.771 202.627 168.771L261.416 168.771' />
        <line x1='238.136' y1='98.8184' x2='196.76' y2='139.707' />
        <line x1='135.688' y1='200.957' x2='94.3128' y2='241.845' />
        <line x1='133.689' y1='137.524' x2='92.5566' y2='96.3914' />
        <line x1='237.679' y1='241.803' x2='196.547' y2='200.671' />
      </g>
    </svg>
  )
}

function AvatarBubble({ avatar }: { avatar: AvatarItem }) {
  const [failed, setFailed] = useState(false)

  return (
    <div
      tabIndex={0}
      role='img'
      aria-label={avatar.name}
      className={cn(
        'group/avatar ring-background bg-muted text-muted-foreground relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full text-xs ring-2 transition-all duration-300 ease-in-out hover:z-10 hover:-translate-y-1 hover:shadow-md focus-visible:z-10 focus-visible:-translate-y-1',
        focusRing
      )}
    >
      {failed ? (
        avatar.fallback
      ) : (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={avatar.src} alt='' className='size-full object-cover' onError={() => setFailed(true)} />
      )}
      <span
        role='tooltip'
        className='bg-foreground text-background pointer-events-none absolute -top-8 left-1/2 hidden -translate-x-1/2 rounded-md px-2 py-1 text-xs whitespace-nowrap group-hover/avatar:block group-focus-visible/avatar:block'
      >
        {avatar.name}
      </span>
    </div>
  )
}

function ThreadCard({ thread, threadIcon }: { thread: ThreadItem; threadIcon: string }) {
  return (
    <article className='bg-card text-card-foreground border-border relative h-auto w-75 overflow-hidden rounded-xl border'>
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img src={thread.image} alt='' className='h-auto w-full' />
      <div className='bg-card absolute bottom-0 w-full p-6'>
        <div className='mb-2 flex items-center gap-2'>
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img src={threadIcon} alt='' className='size-5.5 rounded-sm' />
          <p className='grow text-lg font-medium'>{thread.username}</p>
          <span className='text-muted-foreground text-xs'>{thread.date}</span>
        </div>
        <p className='text-muted-foreground text-base'>{thread.body}</p>
      </div>
    </article>
  )
}

export function Waitlist01({
  threads = DEFAULT_THREADS,
  avatars = DEFAULT_AVATARS,
  brandName = 'shadcn/studio',
  threadIcon = DEFAULT_THREAD_ICON,
  headline = 'Connect & Grow with our community',
  description = "We're onboarding users gradually to maintain reliability, and a high-quality experience for everyone.",
  joinedCount = '25,000+',
  className,
  onSubmit
}: WaitlistProps) {
  const [email, setEmail] = useState('')
  const [joined, setJoined] = useState(false)

  const first = threads.slice(0, 3)
  const second = threads.slice(3, 5)

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    onSubmit?.(email)
    setJoined(true)
  }

  const renderCards = (items: ThreadItem[]) =>
    items.map((item, index) => <ThreadCard key={`${item.username}-${index}`} thread={item} threadIcon={threadIcon} />)

  return (
    <div className={cn('bg-background text-foreground w-full', className)}>
      <style>{MARQUEE_CSS}</style>

      <header className='bg-background border-border sticky top-0 z-50 w-full border-b'>
        <div className='mx-auto flex max-w-7xl items-center justify-between gap-8 px-4 py-3.5 sm:px-8'>
          <a href='#' className={cn('flex items-center gap-3 rounded-md', focusRing)}>
            <LogoMark className='size-8.5' />
            <span className='text-xl font-bold'>{brandName}</span>
          </a>
          <a
            href='#waitlist-email'
            className={cn(
              'bg-primary text-primary-foreground inline-flex h-10 items-center justify-center rounded-lg px-4 text-sm font-medium transition-colors hover:bg-primary/90 max-md:hidden',
              focusRing
            )}
          >
            Early access
          </a>
        </div>
      </header>

      <div className='relative flex h-full flex-col overflow-hidden'>
        <div className='from-background absolute inset-y-0 right-0 z-1 w-20.5 bg-linear-to-l to-transparent max-lg:hidden' />
        <div className='from-background absolute inset-x-0 top-0 z-1 h-20.5 bg-linear-to-b to-transparent max-lg:hidden' />
        <div className='from-background absolute inset-x-0 bottom-0 z-1 h-20.5 bg-linear-to-t to-transparent max-lg:hidden' />

        <div className='bg-background mx-auto grid max-w-7xl gap-20 lg:grid-cols-2'>
          <div className='col-span-1 max-lg:mt-24'>
            <div className='flex h-full flex-col gap-6 px-4 md:max-xl:px-8 lg:w-lg lg:justify-center xl:pl-8'>
              <div className='space-y-4'>
                <h1 className='text-xl font-semibold md:text-2xl lg:text-3xl xl:text-5xl'>{headline}</h1>
                <p className='text-muted-foreground text-lg lg:text-xl'>{description}</p>
              </div>

              <form onSubmit={handleSubmit} className='flex gap-2.5 max-sm:flex-col'>
                <label htmlFor='waitlist-email' className='sr-only'>
                  Email address
                </label>
                <input
                  id='waitlist-email'
                  type='email'
                  required
                  value={email}
                  onChange={e => {
                    setEmail(e.target.value)
                    setJoined(false)
                  }}
                  placeholder='Enter your email address'
                  autoComplete='email'
                  className={cn(
                    'border-input bg-background text-foreground placeholder:text-muted-foreground h-10 rounded-lg border px-3 text-sm max-sm:w-full sm:max-w-72 sm:flex-1',
                    focusRing
                  )}
                />
                <button
                  type='submit'
                  className={cn(
                    'bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium transition-colors',
                    focusRing
                  )}
                >
                  {joined ? 'You are on the list' : 'Join Waitlist'}
                  {joined ? <CheckIcon className='size-4' aria-hidden='true' /> : <ArrowRightIcon className='size-4' aria-hidden='true' />}
                </button>
              </form>
              <p role='status' aria-live='polite' className='sr-only'>
                {joined ? 'Thanks, you have joined the waitlist.' : ''}
              </p>

              <div className='flex items-center gap-2'>
                <div className='flex -space-x-4'>
                  {avatars.map(avatar => (
                    <AvatarBubble key={avatar.name} avatar={avatar} />
                  ))}
                </div>
                <p className='text-muted-foreground max-w-65 text-sm'>
                  Over <span className='text-foreground font-medium'>{joinedCount}</span> people have already joined you
                  could be next.
                </p>
              </div>
            </div>
          </div>

          <div className='relative col-span-1 max-lg:overflow-hidden'>
            <div className='from-background absolute inset-x-0 top-0 z-1 h-10 bg-linear-to-b to-transparent lg:hidden' />
            <div className='from-background absolute inset-x-0 bottom-0 z-1 h-10 bg-linear-to-t to-transparent lg:hidden' />

            <div className='flex max-h-95 sm:rotate-z-16 sm:max-lg:ml-25 md:max-h-121 lg:max-h-dvh'>
              <div className='w-full max-sm:-mt-10'>
                <VerticalMarquee className='mx-auto px-1 max-sm:w-77'>{renderCards(first)}</VerticalMarquee>
              </div>
              <div className='-mt-20 w-full max-sm:hidden'>
                <VerticalMarquee reverse>{renderCards(second)}</VerticalMarquee>
              </div>
              <div className='w-full max-lg:hidden lg:-mt-20'>
                <VerticalMarquee>{renderCards(first)}</VerticalMarquee>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

export function Waitlist01Demo() {
  return <Waitlist01 />
}

export default Waitlist01

Dependencies

More by Clevision

View all
  • Calendar

    Calendar

    109
  • Card Studio

    Card Studio

    103
  • Collapsible

    Collapsible

    43
  • Button Group

    Button Group

    38
  • Buttons

    Buttons

    34
  • Radio Group 1

    Radio Group 1

    30
  • Combobox

    Combobox

    29
  • Basic Avatar

    Basic Avatar

    26