Waitlist hero
A hero whose only job is one email address, with validation and inline social proof.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState, type FormEvent } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { AvatarGroup } from '@/components/ui/avatar'
import { team } from '@/content/demo'
/**
* Waitlist hero
*
* A hero whose only job is one email address. The form is inline rather than
* below the fold, the field is the largest control on the page, and the social
* proof sits directly beneath it — the three things that decide whether a
* waitlist hero works.
*/
export default function WaitlistHero() {
const [value, setValue] = useState('')
const [error, setError] = useState<string | null>(null)
const [joined, setJoined] = useState(false)
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmed = value.trim()
if (!trimmed) return setError('Enter an email address.')
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(trimmed)) {
return setError('Enter a valid email address, for example name@company.com.')
}
setError(null)
setJoined(true)
}
return (
<section className="border-b border-line bg-canvas py-section">
<Container size="narrow" className="text-center">
<h2 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
Early access opens in April.
</h2>
<p className="mx-auto mt-4 max-w-lg text-md text-ink-muted">
We are letting teams in a few at a time so onboarding stays personal. Join the list and we
will be in touch.
</p>
<div className="mx-auto mt-8 max-w-md">
{joined ? (
<div
role="status"
className="flex items-center justify-center gap-2.5 rounded-lg border border-success-line bg-success-soft px-4 py-4 text-sm"
>
<CheckCircle2 className="size-4 shrink-0 text-success" aria-hidden="true" />
<span className="text-ink">
You are on the list. This is a template demo — no submission was sent.
</span>
</div>
) : (
<form
onSubmit={submit}
noValidate
className="flex flex-col gap-3 sm:flex-row sm:items-start"
>
<Field
name="waitlist-email"
label="Email address"
hideLabel
error={error}
className="flex-1 text-left"
>
{(field) => (
<Input
{...field}
type="email"
inputSize="lg"
autoComplete="email"
placeholder="you@company.com"
value={value}
onChange={(event) => {
setValue(event.target.value)
if (error) setError(null)
}}
/>
)}
</Field>
<Button type="submit" size="lg" className="shrink-0">
Join the list
</Button>
</form>
)}
</div>
<div className="mt-8 flex flex-col items-center gap-2.5">
<AvatarGroup
names={team.slice(0, 5).map((member) => member.name)}
size="sm"
label="People already on the waitlist"
/>
<p className="text-sm text-ink-muted">1,240 teams waiting</p>
</div>
</Container>
</section>
)
}
components/blocks/sections/hero/waitlist.tsx
'use client'
import { useState, type FormEvent } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { AvatarGroup } from '@/components/ui/avatar'
import { team } from '@/content/demo'
/**
* Waitlist hero
*
* A hero whose only job is one email address. The form is inline rather than
* below the fold, the field is the largest control on the page, and the social
* proof sits directly beneath it — the three things that decide whether a
* waitlist hero works.
*/
export default function WaitlistHero() {
const [value, setValue] = useState('')
const [error, setError] = useState<string | null>(null)
const [joined, setJoined] = useState(false)
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmed = value.trim()
if (!trimmed) return setError('Enter an email address.')
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(trimmed)) {
return setError('Enter a valid email address, for example name@company.com.')
}
setError(null)
setJoined(true)
}
return (
<section className="border-b border-line bg-canvas py-section">
<Container size="narrow" className="text-center">
<h2 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
Early access opens in April.
</h2>
<p className="mx-auto mt-4 max-w-lg text-md text-ink-muted">
We are letting teams in a few at a time so onboarding stays personal. Join the list and we
will be in touch.
</p>
<div className="mx-auto mt-8 max-w-md">
{joined ? (
<div
role="status"
className="flex items-center justify-center gap-2.5 rounded-lg border border-success-line bg-success-soft px-4 py-4 text-sm"
>
<CheckCircle2 className="size-4 shrink-0 text-success" aria-hidden="true" />
<span className="text-ink">
You are on the list. This is a template demo — no submission was sent.
</span>
</div>
) : (
<form
onSubmit={submit}
noValidate
className="flex flex-col gap-3 sm:flex-row sm:items-start"
>
<Field
name="waitlist-email"
label="Email address"
hideLabel
error={error}
className="flex-1 text-left"
>
{(field) => (
<Input
{...field}
type="email"
inputSize="lg"
autoComplete="email"
placeholder="you@company.com"
value={value}
onChange={(event) => {
setValue(event.target.value)
if (error) setError(null)
}}
/>
)}
</Field>
<Button type="submit" size="lg" className="shrink-0">
Join the list
</Button>
</form>
)}
</div>
<div className="mt-8 flex flex-col items-center gap-2.5">
<AvatarGroup
names={team.slice(0, 5).map((member) => member.name)}
size="sm"
label="People already on the waitlist"
/>
<p className="text-sm text-ink-muted">1,240 teams waiting</p>
</div>
</Container>
</section>
)
}
components/ui/field.tsx
import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Field
*
* The accessibility contract for every form control in Foundry lives here, in
* one place, rather than being re-implemented per input.
*
* Ids are derived deterministically from `name` instead of `useId`, for two
* reasons: the component stays renderable from a Server Component (no hooks),
* and the markup is byte-identical between server and client, so a form works
* before hydration — see `/docs/accessibility`.
*
* The render prop hands back the exact wiring a control needs, which makes it
* impossible to forget `aria-describedby` on a field that has help text.
*/
export interface FieldRenderProps {
id: string
name: string
'aria-describedby': string | undefined
'aria-invalid': true | undefined
'aria-required': true | undefined
required: boolean
invalid: boolean
}
export interface FieldProps {
name: string
label: ReactNode
hint?: ReactNode
/** Validation failure. Presence flips the field into its invalid state. */
error?: string | null
/** Confirmation message shown once a value has validated. */
success?: string | null
required?: boolean
/** Marks the field "Optional" instead of marking required fields. */
showOptional?: boolean
/** Visually hides the label while keeping it available to screen readers. */
hideLabel?: boolean
className?: string
/** Disambiguates ids when the same field name appears twice on one page. */
idPrefix?: string
children: (props: FieldRenderProps) => ReactNode
}
export function Field({
name,
label,
hint,
error,
success,
required = false,
showOptional = false,
hideLabel = false,
className,
idPrefix = 'field',
children,
}: FieldProps) {
const id = `${idPrefix}-${name}`
const hintId = hint ? `${id}-hint` : undefined
const errorId = error ? `${id}-error` : undefined
const successId = success && !error ? `${id}-success` : undefined
const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined
return (
<div className={cn('flex flex-col gap-1.5', className)}>
<label
htmlFor={id}
className={cn(
'flex items-baseline gap-1.5 text-sm font-medium text-ink',
hideLabel && 'sr-only',
)}
>
<span>{label}</span>
{required && !showOptional ? (
<span className="text-danger" aria-hidden="true">
*
</span>
) : null}
{showOptional && !required ? (
<span className="text-xs font-normal text-ink-subtle">Optional</span>
) : null}
</label>
{hint ? (
<p id={hintId} className="text-xs leading-normal text-ink-muted">
{hint}
</p>
) : null}
{children({
id,
name,
'aria-describedby': describedBy,
'aria-invalid': error ? true : undefined,
'aria-required': required || undefined,
required,
invalid: Boolean(error),
})}
{error ? (
<p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
<AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{error}</span>
</p>
) : null}
{success && !error ? (
<p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
<CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{success}</span>
</p>
) : null}
</div>
)
}
/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
legend: ReactNode
hint?: ReactNode
error?: string | null
name: string
required?: boolean
className?: string
children: ReactNode
idPrefix?: string
}
export function Fieldset({
legend,
hint,
error,
name,
required = false,
className,
children,
idPrefix = 'group',
}: FieldsetProps) {
const id = `${idPrefix}-${name}`
const hintId = hint ? `${id}-hint` : undefined
const errorId = error ? `${id}-error` : undefined
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined
return (
<fieldset
className={cn('flex min-w-0 flex-col gap-2', className)}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
aria-required={required || undefined}
>
<legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
<span>{legend}</span>
{required ? (
<span className="text-danger" aria-hidden="true">
*
</span>
) : null}
</legend>
{hint ? (
<p id={hintId} className="text-xs text-ink-muted">
{hint}
</p>
) : null}
{children}
{error ? (
<p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
<AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{error}</span>
</p>
) : null}
</fieldset>
)
}
components/ui/avatar.tsx
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { initials as toInitials } from '@/lib/format'
/**
* Avatar / AvatarGroup
*
* Foundry ships no photography, so avatars render deterministic initials on a
* tinted surface. The tint is derived from the name's character codes, which
* keeps the same person the same colour on every page without a colour field
* in the data.
*
* A decorative avatar next to a visible name is `aria-hidden`; a standalone
* one exposes the name as its label.
*/
export interface AvatarProps {
name: string
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/** Suppresses the accessible name when the name is already on screen. */
decorative?: boolean
/** Small badge anchored bottom-right, e.g. a presence dot. */
indicator?: ReactNode
shape?: 'circle' | 'square'
className?: string
}
const sizes = {
xs: 'size-5 text-2xs',
sm: 'size-7 text-2xs',
md: 'size-9 text-xs',
lg: 'size-12 text-sm',
xl: 'size-16 text-lg',
} as const
const tints = [
'bg-accent-soft text-accent-soft-ink',
'bg-success-soft text-success',
'bg-warning-soft text-warning',
'bg-info-soft text-info',
'bg-danger-soft text-danger',
'bg-surface-sunken text-ink-muted',
] as const
function tintFor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 997
return tints[hash % tints.length] ?? tints[0]
}
export function Avatar({
name,
size = 'md',
decorative = false,
indicator,
shape = 'circle',
className,
}: AvatarProps) {
return (
<span className={cn('relative inline-flex shrink-0', className)}>
<span
role={decorative ? undefined : 'img'}
aria-label={decorative ? undefined : name}
aria-hidden={decorative || undefined}
className={cn(
'inline-flex items-center justify-center border border-line font-semibold select-none',
shape === 'circle' ? 'rounded-full' : 'rounded-md',
sizes[size],
tintFor(name),
)}
>
{toInitials(name)}
</span>
{indicator ? <span className="absolute -right-0.5 -bottom-0.5">{indicator}</span> : null}
</span>
)
}
export interface AvatarGroupProps {
names: string[]
size?: AvatarProps['size']
/** Names beyond this count collapse into a "+n" chip. */
max?: number
className?: string
label?: string
}
export function AvatarGroup({ names, size = 'sm', max = 4, className, label }: AvatarGroupProps) {
const visible = names.slice(0, max)
const overflow = names.length - visible.length
return (
<span
className={cn('flex items-center', className)}
role="group"
aria-label={label ?? `${names.length} people`}
>
{visible.map((name) => (
<span
key={name}
className="-ml-2 first:ml-0 ring-2 ring-[var(--color-surface)] rounded-full"
>
<Avatar name={name} size={size} decorative />
</span>
))}
{overflow > 0 ? (
<span
className={cn(
'-ml-2 inline-flex items-center justify-center rounded-full border border-line bg-surface-sunken font-semibold text-ink-muted ring-2 ring-[var(--color-surface)]',
sizes[size],
)}
>
+{overflow}
</span>
) : null}
<span className="sr-only">{names.join(', ')}</span>
</span>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The form is inline rather than below the fold, the field is the largest control on the page, and the proof sits directly beneath it. Those three decisions are what make a waitlist hero work.
- Validate on submit and clear the error on input — nagging mid-word costs signups.
- The success state states plainly that nothing was sent.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Inline form
- Invalid email
- Joined state
Accessibility
- Hidden label
- The field has a real label, visually hidden rather than replaced by a placeholder.
- Status role
- The joined confirmation is announced politely.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All sectionsInline newsletter
One row: heading, field and button, with the privacy line in the pattern.
starter3 variantsStartup hero
A direct claim, one action, and social proof built from faces rather than logos.
starter3 variantsMagic link
A one-field sign-in whose success state names the address and offers a correction path.
starter4 variants