Billing help card with seat usage, invoice details, a focusable disabled invite action, and collapsible answers explaining the seat limit and pending invites.
import {
CaretDownIcon,
UserPlusIcon,
WarningCircleIcon,
} from "@phosphor-icons/react/dist/ssr"
import { cn } from "cn"
import {
DescriptionList,
DescriptionListDetails,
DescriptionListItem,
DescriptionListTerm,
} from "@/components/editorial/description-list"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Progress, ProgressLabel } from "@/components/ui/progress"
// The copy: change text here, not in the markup.
const COPY = {
title: "Seats",
plan: "Team plan, 10 seats",
usage: { label: "Seats used", value: "10 of 10" },
limit: "Seat limit reached",
invite: "Invite member",
} as const
const DETAILS = [
{ term: "Members", details: "8" },
{ term: "Pending invites", details: "2" },
{ term: "Next invoice", details: "1 November 2026" },
{ term: "10 seats at €14", details: "€140.00" },
] as const
const QUESTIONS = [
{
id: "limit",
question: "Why can’t I invite anyone?",
answer:
"All 10 seats are taken: 8 by members and 2 by pending invites. Add a seat, or withdraw an invite, and the invite button works straight away. Each extra seat is €14 a month, and this month you pay only for the days left.",
action: { label: "Add seats", href: "#" },
open: true,
},
{
id: "pending",
question: "What happens to pending invites?",
answer:
"Each one holds a seat until the person accepts it. Invites nobody accepts are cancelled after 14 days, which frees the seat, or you can withdraw one yourself under Members.",
open: false,
},
] as const
export default function SeatLimitHelp() {
return (
<Card
size="sm"
className="@container/seats mx-auto w-full max-w-md min-w-0 pb-2 wrap-anywhere"
>
<CardHeader>
<CardTitle>
<h2>{COPY.title}</h2>
</CardTitle>
<CardDescription>{COPY.plan}</CardDescription>
</CardHeader>
<CardContent className="grid min-w-0 gap-6">
{/* aria-valuetext reads the count the way it is written, not as 100%. */}
<Progress
value={10}
max={10}
aria-valuetext={COPY.usage.value}
className="min-w-0"
>
<ProgressLabel>{COPY.usage.label}</ProgressLabel>
<span
aria-hidden="true"
className="ml-auto min-w-0 text-sm text-muted-foreground tabular-nums"
>
{COPY.usage.value}
</span>
</Progress>
<DescriptionList>
{DETAILS.map((row) => (
<DescriptionListItem key={row.term}>
<DescriptionListTerm>{row.term}</DescriptionListTerm>
<DescriptionListDetails>{row.details}</DescriptionListDetails>
</DescriptionListItem>
))}
</DescriptionList>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="flex min-w-0 items-center gap-2 text-sm font-medium text-destructive">
<WarningCircleIcon aria-hidden className="size-4 shrink-0" />
{COPY.limit}
</p>
{/* Focusable while disabled, so a keyboard or screen reader still
reaches it and hears why. */}
<Button
disabled
focusableWhenDisabled
aria-description={COPY.limit}
className="h-auto min-h-9 max-w-full min-w-0 py-1.5 whitespace-normal data-disabled:pointer-events-none data-disabled:opacity-50"
>
<UserPlusIcon
data-icon="inline-start"
aria-hidden
className="@max-[16rem]/seats:hidden"
/>
{COPY.invite}
</Button>
</div>
</CardContent>
<div className="divide-y border-t">
{QUESTIONS.map((item) => (
<Collapsible key={item.id} defaultOpen={item.open}>
<h3>
<CollapsibleTrigger className="group/question flex w-full items-center justify-between gap-3 px-(--card-spacing) py-4 text-left text-sm font-medium hover:underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring">
{item.question}
<CaretDownIcon
aria-hidden
className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open/question:rotate-180 motion-reduce:transition-none"
/>
</CollapsibleTrigger>
</h3>
<CollapsibleContent className="h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0 motion-reduce:transition-none">
<div className="grid justify-items-start gap-3 px-(--card-spacing) pb-4 text-sm/6 text-muted-foreground">
<p>{item.answer}</p>
{"action" in item && (
<a
href={item.action.href}
data-slot="button"
className={cn(
buttonVariants({
variant: "outline",
size: "sm",
}),
"h-auto min-h-8 max-w-full py-1.5 text-center whitespace-normal text-foreground"
)}
>
{item.action.label}
</a>
)}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
</Card>
)
}
pnpm dlx shadcn add @01orbital/seat-limit-helpnpx shadcn add @01orbital/seat-limit-helpyarn shadcn add @01orbital/seat-limit-helpbunx --bun shadcn add @01orbital/seat-limit-helpFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/blocks/faq/seat-limit-help/index.tsx, as source you own and edit.
import SeatLimitHelp from "@/components/blocks/faq/seat-limit-help"
export default function Example() {
return <SeatLimitHelp />
}npm: @phosphor-icons/react, cn. The CLI installs them for you.
Registry pieces it installs with it:
buttoncardcollapsibledescription-listprogressHelp Search
Searchable help card that filters question and answer text as you type, announces the result count, and offers clear-search and support actions when nothing matches.
Split-Row Spaceflight FAQ
Single-column FAQ in split rows: each question sits left of its answer on wide screens, separated by rules. Copy is a fictional spaceflight booking guide.