Waitlist 1: referral waitlist
A launch waitlist that grows by referral: an email form with a count of who is waiting, then the person's place in line, their own share link to copy, and a ladder of rewards that fills as friends join.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Waitlist 1. https://ui.ballmac.com/blocks/waitlist-1
"use client"
import * as React from "react"
import { ArrowRight, CircleAlert, Gift, Loader2, Lock, Mail, Share2 } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { CopyButton } from "@/components/ballmac/copy-button"
import { CoverArt } from "@/components/ballmac/cover-art"
import { Input } from "@/components/ballmac/input"
import { NumberTicker } from "@/components/ballmac/number-ticker"
import { formPhase, type FormStateProps } from "@/lib/ballmac/use-resource"
import { cn } from "@/lib/utils"
type Waitlist1Joined = { /** Their place in line, 1 is first. */ position: number; /** Their share link. */ link: string; /** How many friends have joined from it. */ referrals: number }
type Waitlist1Reward = { /** Friends needed. */ at: number; title: string; description?: string }
type Waitlist1Props = Omit<React.ComponentProps<"section">, "title" | "onSubmit"> &
FormStateProps & {
product?: string
title?: string
description?: string
/** How many people are waiting, shown under the form. */
waiting?: number
rewards?: Waitlist1Reward[]
/** Names for the little faces beside the count. */
faces?: string[]
/** Called with the email. Return where they stand; reject to show a message and keep what they typed. */
onJoin?: (email: string) => Promise<Waitlist1Joined> | Waitlist1Joined
/** Start already joined, for showing the second screen. */
defaultJoined?: Waitlist1Joined
}
const sampleRewards: Waitlist1Reward[] = [
{ at: 1, title: "Skip ahead 100 places", description: "For your first friend" },
{ at: 3, title: "Early access", description: "In the first wave of invites" },
{ at: 10, title: "A year free", description: "On any plan" },
]
const names = ["Mara E", "Theo N", "Priya S", "Noor H", "Kofi M"]
/**
* A launch waitlist that grows by referral. First the email form and how many are waiting; once joined, the person sees their place in line,
* their own share link, and a ladder of rewards that fills as friends join. The place counts up, the link copies with one press, and a
* failed join keeps what was typed.
*/
function Waitlist1({
product = "Acme",
title,
description = "We let people in a few at a time so every one gets our full attention. Join the list, and move up by bringing friends.",
waiting = 2431,
rewards = sampleRewards,
faces = names,
onJoin,
defaultJoined,
state: stateProp,
errorState,
className,
...props
}: Waitlist1Props) {
const id = React.useId()
const [email, setEmail] = React.useState("")
const [invalid, setInvalid] = React.useState<string>()
const [internal, setInternal] = React.useState<"idle" | "sending" | "done" | "error">(defaultJoined ? "done" : "idle")
const [joined, setJoined] = React.useState<Waitlist1Joined | undefined>(defaultJoined)
const [message, setMessage] = React.useState<string>()
const state = formPhase(stateProp) ?? internal
const headingRef = React.useRef<HTMLHeadingElement>(null)
const first = React.useRef(true)
const done = state === "done"
const sending = state === "sending"
const info = joined ?? { position: 1284, link: "acme.com/r/mara-7k2", referrals: 1 }
React.useEffect(() => {
if (first.current) first.current = false
else if (done) headingRef.current?.focus()
}, [done])
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!/^\S+@\S+\.\S+$/.test(email.trim())) return setInvalid("Enter an email address like name@company.com.")
setInvalid(undefined)
setMessage(undefined)
setInternal("sending")
try {
if (onJoin) setJoined(await onJoin(email.trim()))
else {
await new Promise((r) => setTimeout(r, 800))
setJoined({ position: waiting + 1, link: "acme.com/r/you-7k2", referrals: 0 })
}
setInternal("done")
} catch (err) {
setMessage(err instanceof Error ? err.message : undefined)
setInternal("error")
}
}
const next = rewards.find((r) => r.at > info.referrals)
const shareText = encodeURIComponent(`I just joined the ${product} waitlist. Join with my link and we both move up:`)
return (
<section data-slot="waitlist-1" aria-labelledby={`${id}-h`} aria-busy={sending} className={cn("relative isolate w-full overflow-hidden", className)} {...props}>
<div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-80 opacity-60 [mask-image:linear-gradient(to_bottom,black,transparent)]">
<CoverArt seed={`${product}-waitlist`} variant="mesh" className="absolute inset-0" />
</div>
<div className="mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 sm:py-24">
{!done ? (
<div className="text-center">
<p className="bg-background/70 inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium backdrop-blur">
<Lock aria-hidden="true" className="size-3.5" />
Invite only for now
</p>
<h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mx-auto mt-6 max-w-xl text-5xl font-semibold tracking-[-0.045em] text-balance outline-none sm:text-6xl">
{title ?? `Be first in line for ${product}`}
</h2>
<p className="text-muted-foreground mx-auto mt-5 max-w-xl text-lg text-pretty">{description}</p>
<form onSubmit={submit} noValidate className="mx-auto mt-10 max-w-md">
<div className="flex flex-col gap-2.5 sm:flex-row">
<div className="flex-1 text-start">
<Input type="email" size="lg" value={email} onChange={(e) => setEmail(e.target.value)} aria-label="Email address" placeholder="you@company.com" autoComplete="email" disabled={sending} aria-invalid={!!invalid} aria-describedby={invalid ? `${id}-err` : undefined} />
</div>
<Button type="submit" size="lg" disabled={sending}>
{sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
{sending ? "Joining" : "Join the waitlist"}
{sending ? null : <ArrowRight aria-hidden="true" className="rtl:rotate-180" />}
</Button>
</div>
{invalid ? (
<p id={`${id}-err`} role="alert" className="text-destructive mt-3 text-start text-sm">
{invalid}
</p>
) : null}
{state === "error" ? (
<p role="alert" className="text-destructive mt-3 flex items-start gap-2 text-start text-sm">
<CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{errorState?.description ?? message ?? "We could not add you just now. Your email is still here, so try again."}
</p>
) : null}
</form>
<div className="mt-8 flex items-center justify-center gap-3">
<ul aria-hidden="true" className="flex -space-x-2">
{faces.map((f) => (
<li key={f} className="border-background relative flex size-8 items-center justify-center overflow-hidden rounded-full border-2 text-[10px] font-semibold">
<CoverArt seed={f} variant="mesh" className="absolute inset-0" />
<span className="relative">{f[0]}</span>
</li>
))}
</ul>
<p className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold tabular-nums">{new Intl.NumberFormat("en").format(waiting)}</span> people are waiting
</p>
</div>
</div>
) : (
<div>
<div className="text-center" role="status">
<p className="text-muted-foreground text-sm font-medium">You are on the list</p>
<h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-3 text-6xl font-semibold tracking-[-0.05em] outline-none sm:text-8xl">
<span aria-hidden="true">
#<NumberTicker value={info.position} />
</span>
<span className="sr-only">Number {new Intl.NumberFormat("en").format(info.position)}</span>
</h2>
<p className="text-muted-foreground mx-auto mt-3 max-w-sm text-pretty">
in line for {product}. {next ? `Bring ${next.at - info.referrals} more ${next.at - info.referrals === 1 ? "friend" : "friends"} and you unlock ${next.title.toLowerCase()}.` : "You have unlocked every reward. Thank you for spreading the word."}
</p>
</div>
<div className="bg-card mt-10 rounded-3xl border p-5 sm:p-7">
<h3 className="flex items-center gap-2 font-semibold tracking-tight">
<Share2 aria-hidden="true" className="size-4" />
Your link
</h3>
<div className="mt-3 flex items-center gap-2 rounded-xl border p-1.5 ps-4">
<p className="min-w-0 flex-1 truncate font-mono text-sm" dir="ltr">
{info.link}
</p>
<CopyButton value={`https://${info.link}`} label="Copy link" copiedLabel="Copied" variant="solid" />
</div>
<div className="mt-3 flex flex-wrap gap-2">
<a href={`mailto:?subject=${encodeURIComponent(`Join me on the ${product} waitlist`)}&body=${shareText}%20https://${info.link}`} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center gap-2 rounded-md border px-3.5 text-sm font-medium outline-none focus-visible:ring-[3px]">
<Mail aria-hidden="true" className="size-4" />
Email a friend
</a>
<a href={`https://twitter.com/intent/tweet?text=${shareText}&url=https://${info.link}`} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center gap-2 rounded-md border px-3.5 text-sm font-medium outline-none focus-visible:ring-[3px]" rel="noreferrer" target="_blank">
Post on X<span className="sr-only"> (opens in a new tab)</span>
</a>
</div>
</div>
<div className="bg-card mt-5 rounded-3xl border p-5 sm:p-7">
<h3 className="flex items-center gap-2 font-semibold tracking-tight">
<Gift aria-hidden="true" className="size-4" />
Rewards <span className="text-muted-foreground font-normal tabular-nums">· {info.referrals} {info.referrals === 1 ? "friend" : "friends"} joined</span>
</h3>
<ol className="mt-5 grid gap-0">
{rewards.map((r, i) => {
const got = info.referrals >= r.at
const last = i === rewards.length - 1
return (
<li key={r.at} className="relative flex gap-4 pb-6 last:pb-0">
{!last ? <span aria-hidden="true" className={cn("absolute start-[15px] top-8 -bottom-0 w-px", got ? "bg-foreground" : "bg-border")} /> : null}
<span aria-hidden="true" className={cn("relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-semibold tabular-nums transition-colors motion-reduce:transition-none", got ? "bg-foreground text-background border-foreground" : "bg-background")}>
{got ? "✓" : r.at}
</span>
<div className="min-w-0 pt-1">
<p className="text-sm font-medium">
{r.title}
<span className="sr-only">, {got ? "unlocked" : `needs ${r.at - info.referrals} more ${r.at - info.referrals === 1 ? "friend" : "friends"}`}</span>
</p>
<p className="text-muted-foreground text-sm">
{r.description ? `${r.description} · ` : ""}
{r.at} {r.at === 1 ? "friend" : "friends"}
</p>
</div>
</li>
)
})}
</ol>
</div>
</div>
)}
</div>
</section>
)
}
export { Waitlist1, type Waitlist1Props, type Waitlist1Joined, type Waitlist1Reward }Installation
$ pnpm dlx shadcn@latest add @ballmac/waitlist-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/copy-button @ballmac/cover-art @ballmac/input @ballmac/number-ticker @ballmac/use-resourceCopy the source into your project.
components/ballmac/blocks/waitlist-1/waitlist-1.tsx// Ballmac UI: Waitlist 1. https://ui.ballmac.com/blocks/waitlist-1 "use client" import * as React from "react" import { ArrowRight, CircleAlert, Gift, Loader2, Lock, Mail, Share2 } from "lucide-react" import { Button } from "@/components/ballmac/button" import { CopyButton } from "@/components/ballmac/copy-button" import { CoverArt } from "@/components/ballmac/cover-art" import { Input } from "@/components/ballmac/input" import { NumberTicker } from "@/components/ballmac/number-ticker" import { formPhase, type FormStateProps } from "@/lib/ballmac/use-resource" import { cn } from "@/lib/utils" type Waitlist1Joined = { /** Their place in line, 1 is first. */ position: number; /** Their share link. */ link: string; /** How many friends have joined from it. */ referrals: number } type Waitlist1Reward = { /** Friends needed. */ at: number; title: string; description?: string } type Waitlist1Props = Omit<React.ComponentProps<"section">, "title" | "onSubmit"> & FormStateProps & { product?: string title?: string description?: string /** How many people are waiting, shown under the form. */ waiting?: number rewards?: Waitlist1Reward[] /** Names for the little faces beside the count. */ faces?: string[] /** Called with the email. Return where they stand; reject to show a message and keep what they typed. */ onJoin?: (email: string) => Promise<Waitlist1Joined> | Waitlist1Joined /** Start already joined, for showing the second screen. */ defaultJoined?: Waitlist1Joined } const sampleRewards: Waitlist1Reward[] = [ { at: 1, title: "Skip ahead 100 places", description: "For your first friend" }, { at: 3, title: "Early access", description: "In the first wave of invites" }, { at: 10, title: "A year free", description: "On any plan" }, ] const names = ["Mara E", "Theo N", "Priya S", "Noor H", "Kofi M"] /** * A launch waitlist that grows by referral. First the email form and how many are waiting; once joined, the person sees their place in line, * their own share link, and a ladder of rewards that fills as friends join. The place counts up, the link copies with one press, and a * failed join keeps what was typed. */ function Waitlist1({ product = "Acme", title, description = "We let people in a few at a time so every one gets our full attention. Join the list, and move up by bringing friends.", waiting = 2431, rewards = sampleRewards, faces = names, onJoin, defaultJoined, state: stateProp, errorState, className, ...props }: Waitlist1Props) { const id = React.useId() const [email, setEmail] = React.useState("") const [invalid, setInvalid] = React.useState<string>() const [internal, setInternal] = React.useState<"idle" | "sending" | "done" | "error">(defaultJoined ? "done" : "idle") const [joined, setJoined] = React.useState<Waitlist1Joined | undefined>(defaultJoined) const [message, setMessage] = React.useState<string>() const state = formPhase(stateProp) ?? internal const headingRef = React.useRef<HTMLHeadingElement>(null) const first = React.useRef(true) const done = state === "done" const sending = state === "sending" const info = joined ?? { position: 1284, link: "acme.com/r/mara-7k2", referrals: 1 } React.useEffect(() => { if (first.current) first.current = false else if (done) headingRef.current?.focus() }, [done]) async function submit(e: React.FormEvent) { e.preventDefault() if (!/^\S+@\S+\.\S+$/.test(email.trim())) return setInvalid("Enter an email address like name@company.com.") setInvalid(undefined) setMessage(undefined) setInternal("sending") try { if (onJoin) setJoined(await onJoin(email.trim())) else { await new Promise((r) => setTimeout(r, 800)) setJoined({ position: waiting + 1, link: "acme.com/r/you-7k2", referrals: 0 }) } setInternal("done") } catch (err) { setMessage(err instanceof Error ? err.message : undefined) setInternal("error") } } const next = rewards.find((r) => r.at > info.referrals) const shareText = encodeURIComponent(`I just joined the ${product} waitlist. Join with my link and we both move up:`) return ( <section data-slot="waitlist-1" aria-labelledby={`${id}-h`} aria-busy={sending} className={cn("relative isolate w-full overflow-hidden", className)} {...props}> <div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-80 opacity-60 [mask-image:linear-gradient(to_bottom,black,transparent)]"> <CoverArt seed={`${product}-waitlist`} variant="mesh" className="absolute inset-0" /> </div> <div className="mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 sm:py-24"> {!done ? ( <div className="text-center"> <p className="bg-background/70 inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium backdrop-blur"> <Lock aria-hidden="true" className="size-3.5" /> Invite only for now </p> <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mx-auto mt-6 max-w-xl text-5xl font-semibold tracking-[-0.045em] text-balance outline-none sm:text-6xl"> {title ?? `Be first in line for ${product}`} </h2> <p className="text-muted-foreground mx-auto mt-5 max-w-xl text-lg text-pretty">{description}</p> <form onSubmit={submit} noValidate className="mx-auto mt-10 max-w-md"> <div className="flex flex-col gap-2.5 sm:flex-row"> <div className="flex-1 text-start"> <Input type="email" size="lg" value={email} onChange={(e) => setEmail(e.target.value)} aria-label="Email address" placeholder="you@company.com" autoComplete="email" disabled={sending} aria-invalid={!!invalid} aria-describedby={invalid ? `${id}-err` : undefined} /> </div> <Button type="submit" size="lg" disabled={sending}> {sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null} {sending ? "Joining" : "Join the waitlist"} {sending ? null : <ArrowRight aria-hidden="true" className="rtl:rotate-180" />} </Button> </div> {invalid ? ( <p id={`${id}-err`} role="alert" className="text-destructive mt-3 text-start text-sm"> {invalid} </p> ) : null} {state === "error" ? ( <p role="alert" className="text-destructive mt-3 flex items-start gap-2 text-start text-sm"> <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> {errorState?.description ?? message ?? "We could not add you just now. Your email is still here, so try again."} </p> ) : null} </form> <div className="mt-8 flex items-center justify-center gap-3"> <ul aria-hidden="true" className="flex -space-x-2"> {faces.map((f) => ( <li key={f} className="border-background relative flex size-8 items-center justify-center overflow-hidden rounded-full border-2 text-[10px] font-semibold"> <CoverArt seed={f} variant="mesh" className="absolute inset-0" /> <span className="relative">{f[0]}</span> </li> ))} </ul> <p className="text-muted-foreground text-sm"> <span className="text-foreground font-semibold tabular-nums">{new Intl.NumberFormat("en").format(waiting)}</span> people are waiting </p> </div> </div> ) : ( <div> <div className="text-center" role="status"> <p className="text-muted-foreground text-sm font-medium">You are on the list</p> <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-3 text-6xl font-semibold tracking-[-0.05em] outline-none sm:text-8xl"> <span aria-hidden="true"> #<NumberTicker value={info.position} /> </span> <span className="sr-only">Number {new Intl.NumberFormat("en").format(info.position)}</span> </h2> <p className="text-muted-foreground mx-auto mt-3 max-w-sm text-pretty"> in line for {product}. {next ? `Bring ${next.at - info.referrals} more ${next.at - info.referrals === 1 ? "friend" : "friends"} and you unlock ${next.title.toLowerCase()}.` : "You have unlocked every reward. Thank you for spreading the word."} </p> </div> <div className="bg-card mt-10 rounded-3xl border p-5 sm:p-7"> <h3 className="flex items-center gap-2 font-semibold tracking-tight"> <Share2 aria-hidden="true" className="size-4" /> Your link </h3> <div className="mt-3 flex items-center gap-2 rounded-xl border p-1.5 ps-4"> <p className="min-w-0 flex-1 truncate font-mono text-sm" dir="ltr"> {info.link} </p> <CopyButton value={`https://${info.link}`} label="Copy link" copiedLabel="Copied" variant="solid" /> </div> <div className="mt-3 flex flex-wrap gap-2"> <a href={`mailto:?subject=${encodeURIComponent(`Join me on the ${product} waitlist`)}&body=${shareText}%20https://${info.link}`} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center gap-2 rounded-md border px-3.5 text-sm font-medium outline-none focus-visible:ring-[3px]"> <Mail aria-hidden="true" className="size-4" /> Email a friend </a> <a href={`https://twitter.com/intent/tweet?text=${shareText}&url=https://${info.link}`} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center gap-2 rounded-md border px-3.5 text-sm font-medium outline-none focus-visible:ring-[3px]" rel="noreferrer" target="_blank"> Post on X<span className="sr-only"> (opens in a new tab)</span> </a> </div> </div> <div className="bg-card mt-5 rounded-3xl border p-5 sm:p-7"> <h3 className="flex items-center gap-2 font-semibold tracking-tight"> <Gift aria-hidden="true" className="size-4" /> Rewards <span className="text-muted-foreground font-normal tabular-nums">· {info.referrals} {info.referrals === 1 ? "friend" : "friends"} joined</span> </h3> <ol className="mt-5 grid gap-0"> {rewards.map((r, i) => { const got = info.referrals >= r.at const last = i === rewards.length - 1 return ( <li key={r.at} className="relative flex gap-4 pb-6 last:pb-0"> {!last ? <span aria-hidden="true" className={cn("absolute start-[15px] top-8 -bottom-0 w-px", got ? "bg-foreground" : "bg-border")} /> : null} <span aria-hidden="true" className={cn("relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-semibold tabular-nums transition-colors motion-reduce:transition-none", got ? "bg-foreground text-background border-foreground" : "bg-background")}> {got ? "✓" : r.at} </span> <div className="min-w-0 pt-1"> <p className="text-sm font-medium"> {r.title} <span className="sr-only">, {got ? "unlocked" : `needs ${r.at - info.referrals} more ${r.at - info.referrals === 1 ? "friend" : "friends"}`}</span> </p> <p className="text-muted-foreground text-sm"> {r.description ? `${r.description} · ` : ""} {r.at} {r.at === 1 ? "friend" : "friends"} </p> </div> </li> ) })} </ol> </div> </div> )} </div> </section> ) } export { Waitlist1, type Waitlist1Props, type Waitlist1Joined, type Waitlist1Reward }Update the import paths to match your project setup.
Usage
"use client"
import { Waitlist1 } from "@/components/ballmac/blocks/waitlist-1/waitlist-1"
export default function Waitlist1Demo() {
return <Waitlist1 />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Joining
The field and button are disabled, the button says Joining with a spinner and the section is marked busy.
state="submitting"On the list
The second screen: place in line, share link and the reward ladder.
state="success"Could not join
An alert saying the email is still there; the person can press Join again.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 4 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
7 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships submitting, success, error states, each with an example.
Passed: Automated tests
Covered by waitlist-1.test.tsx.
For information: Footprint
4.0 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
A referral waitlist. <Waitlist1 product='Acme' waiting={2431} rewards={[{ at: 3, title, description }]} onJoin={(email) => Promise<{ position, link, referrals }>} defaultJoined state />. After joining it shows #position, the share link with a copy button, and rewards that unlock as referrals reaches each at. Reject onJoin to show its message with the email kept. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Waitlist 1: referral waitlist (@ballmac/waitlist-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A pre-launch landing page that collects emails
- Rolling out access in waves with referral rewards
Not for
- A newsletter signup (use newsletter-1)
- A full sign-up form (use auth blocks)
Registry JSON: https://ui.ballmac.com/r/waitlist-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.