Patterns
Radio Card Group
A group of cards where the user selects one option, each with a title and a description.
Patterns
A group of cards where the user selects one option, each with a title and a description.
You can change the visibility later.
import { Field, RadioCardGroup, type RadioCardOption } from 'ferry-ui'
import { Globe, Lock, Users } from 'lucide-react'
const OPTIONS: RadioCardOption[] = [
{ value: 'private', label: 'Private', description: 'Only you can open this project.', icon: <Lock /> },
{ value: 'team', label: 'Team', description: 'Each member of the workspace can open it.', icon: <Users /> },
{ value: 'public', label: 'Public', description: 'Each person with the link can read it.', icon: <Globe /> },
]
export default function RadioCardGroupHero() {
return (
<Field
label="Visibility"
labelAs="span"
hint="You can change the visibility later."
className="mx-auto w-full max-w-md"
>
<RadioCardGroup defaultValue="team" options={OPTIONS} />
</Field>
)
}Import the parts. Give the cards as data in options, or as RadioCard children.
import { RadioCard, RadioCardGroup } from 'ferry-ui'
<RadioCardGroup options={[{ value: '', label: '', description: '' }]} />
<RadioCardGroup>
<RadioCard value="" label="" description="" />
</RadioCardGroup>The group must have a name. Put it in a Field with labelAs="span", or pass aria-label.
The size prop is lg by default. Use sm for a choice in a form. columns sets 1, 2 or 3 columns. A phone shows one column.
import { RadioCardGroup, type RadioCardOption } from 'ferry-ui'
import { Globe, Lock } from 'lucide-react'
const OPTIONS: RadioCardOption[] = [
{ value: 'private', label: 'Private', description: 'Only members can open it.', icon: <Lock /> },
{ value: 'public', label: 'Public', description: 'Each person with the link can read it.', icon: <Globe /> },
]
export default function RadioCardGroupSizes() {
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-6">
<RadioCardGroup aria-label="Visibility, large cards" size="lg" columns={2} defaultValue="private" options={OPTIONS} />
<RadioCardGroup aria-label="Visibility, small cards" size="sm" columns={2} defaultValue="private" options={OPTIONS} />
</div>
)
}The appearance prop sets the look of the selected card. Use soft in a form that is in a card.
import { RadioCardGroup, type RadioCardOption } from 'ferry-ui'
import { Globe, Lock } from 'lucide-react'
const OPTIONS: RadioCardOption[] = [
{ value: 'private', label: 'Private', description: 'Only members can open it.', icon: <Lock /> },
{ value: 'public', label: 'Public', description: 'Each person with the link can read it.', icon: <Globe /> },
]
export default function RadioCardGroupAppearances() {
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-6">
<RadioCardGroup aria-label="Visibility, outline" appearance="outline" size="sm" columns={2} defaultValue="private" options={OPTIONS} />
<RadioCardGroup aria-label="Visibility, soft" appearance="soft" size="sm" columns={2} defaultValue="private" options={OPTIONS} />
</div>
)
}| Appearance | Selected card | Default indicator |
|---|---|---|
outline (default) | A primary outline. | check |
soft | A soft primary fill. | radio |
The indicator prop sets the mark of the selected card.
import { RadioCardGroup, type RadioCardOption } from 'ferry-ui'
const OPTIONS: RadioCardOption[] = [
{ value: 'monthly', label: 'Monthly', description: 'One invoice each month.' },
{ value: 'yearly', label: 'Yearly', description: 'One invoice each year.' },
]
export default function RadioCardGroupIndicators() {
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-6">
<RadioCardGroup aria-label="Billing period, check mark" indicator="check" size="sm" columns={2} defaultValue="monthly" options={OPTIONS} />
<RadioCardGroup aria-label="Billing period, radio mark" indicator="radio" size="sm" columns={2} defaultValue="monthly" options={OPTIONS} />
<RadioCardGroup aria-label="Billing period, no mark" indicator="none" size="sm" columns={2} defaultValue="monthly" options={OPTIONS} />
</div>
)
}| Indicator | Mark |
|---|---|
check | A round check mark in the top right corner. |
radio | A radio dot at the end of the row. |
none | No mark. Only the border or the fill shows the selection. |
Pass value and onValueChange. For a controlled group with no selection, pass null. With undefined, the group is uncontrolled.
import * as React from 'react'
import { Button, RadioCardGroup, type RadioCardOption } from 'ferry-ui'
type Period = 'monthly' | 'yearly'
const OPTIONS: RadioCardOption<Period>[] = [
{ value: 'monthly', label: 'Monthly', description: 'One invoice each month.' },
{ value: 'yearly', label: 'Yearly', description: 'One invoice each year.' },
]
export default function RadioCardGroupControlled() {
// `null`, not `undefined`: the group stays controlled while no card is selected.
const [period, setPeriod] = React.useState<Period | null>(null)
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
<RadioCardGroup
aria-label="Billing period"
size="sm"
columns={2}
options={OPTIONS}
value={period}
onValueChange={setPeriod}
/>
<div className="flex items-center justify-between gap-3">
<span className="text-[13px] text-foreground-light">Selected: {period ?? 'none'}</span>
<Button disabled={period === null} onClick={() => setPeriod(null)}>
Clear
</Button>
</div>
</div>
)
}RadioCard shows its children below the description. Use them for a price or a Badge.
import { Badge, RadioCard, RadioCardGroup } from 'ferry-ui'
const PLANS = [
{ value: 'free', label: 'Free', description: 'For one member and three projects.', price: '$0', popular: false },
{ value: 'pro', label: 'Pro', description: 'For a team, with no limit on projects.', price: '$19', popular: true },
{ value: 'team', label: 'Team', description: 'With roles and an audit log.', price: '$49', popular: false },
]
export default function RadioCardGroupChildren() {
return (
<RadioCardGroup aria-label="Plan" columns={3} defaultValue="pro" className="mx-auto w-full max-w-2xl">
{PLANS.map((plan) => (
<RadioCard key={plan.value} value={plan.value} label={plan.label} description={plan.description}>
{/* Text and a badge only: the card is a button, so it cannot hold a button or a link. */}
<span className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="text-[13px] font-medium text-foreground tabular">
{plan.price}
<span className="font-normal text-foreground-lighter"> / month</span>
</span>
{plan.popular && <Badge>Popular</Badge>}
</span>
</RadioCard>
))}
</RadioCardGroup>
)
}media puts a preview above the label. Use it to select a layout or an appearance.
import { RadioCard, RadioCardGroup } from 'ferry-ui'
// A small drawing of a page layout, made with token classes.
function Preview({ sidebar = false }: { sidebar?: boolean }) {
return (
<span className="flex h-20 w-full gap-1.5 bg-surface-200 p-2">
{sidebar && <span className="w-1/4 rounded-sm bg-surface-300" />}
<span className="flex flex-1 flex-col gap-1.5">
{!sidebar && <span className="h-2.5 rounded-sm bg-surface-300" />}
<span className="flex-1 rounded-sm border bg-background" />
</span>
</span>
)
}
export default function RadioCardGroupMedia() {
return (
<RadioCardGroup
aria-label="Dashboard layout"
indicator="radio"
defaultValue="sidebar"
className="mx-auto w-full max-w-md grid-cols-2"
>
<RadioCard
value="sidebar"
label="Sidebar"
description="The navigation is on the left."
media={<Preview sidebar />}
/>
<RadioCard
value="top-bar"
label="Top bar"
description="The navigation is above the content."
media={<Preview />}
/>
</RadioCardGroup>
)
}While a Field shows an error, it sets aria-invalid on the group. The cards that are not selected then get a red border.
import * as React from 'react'
import { Button, Field, RadioCardGroup, toast, type RadioCardOption } from 'ferry-ui'
const PLANS: RadioCardOption[] = [
{ value: 'free', label: 'Free', description: 'For one member and three projects.' },
{ value: 'pro', label: 'Pro', description: 'For a team, with no limit on projects.' },
]
export default function RadioCardGroupInvalid() {
const [plan, setPlan] = React.useState<string | null>(null)
const [submitted, setSubmitted] = React.useState(false)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitted(true)
if (plan !== null) toast.success('Plan selected')
}
return (
<form noValidate className="mx-auto flex w-full max-w-xl flex-col gap-5" onSubmit={submit}>
{/* `Field` names the group and sets `aria-invalid` on it while it shows the error. */}
<Field
label="Plan"
labelAs="span"
error={submitted && plan === null ? 'Select a plan to continue.' : undefined}
>
<RadioCardGroup size="sm" columns={2} options={PLANS} value={plan} onValueChange={setPlan} />
</Field>
<Button type="submit" variant="primary" className="self-end">
Continue
</Button>
</form>
)
}label is the accessible name of a card. The description is its accessible description.Each part also accepts the props of its Radix UI primitive, the root and the item of RadioGroup.
| Prop | Type | Default |
|---|---|---|
value | T | null | - |
Selected value (controlled). Pair it with | ||
defaultValue | T | - |
Initially selected value when uncontrolled. Omit both | ||
onValueChange | ((value: T) => void) | - |
Called with the newly selected value (click, Space, or arrow keys). | ||
options | readonly RadioCardOption<T>[] | - |
Shortcut: the cards as data. Rendered before any | ||
size | "sm" | "lg" | lg |
Card size, see | ||
appearance | "outline" | "soft" | outline |
Selected look, see | ||
indicator | "none" | "radio" | "check" | - |
Selection mark, see | ||
columns | 1 | 3 | 2 | - |
Number of columns from the | ||
disabled | boolean | - |
Disables every card. | ||
aria-label | string | - |
Accessible name of the group when no visible label exists. Prefer | ||
aria-labelledby | string | - |
Id of the visible element naming the group (a field label rendered as a | ||
aria-invalid | "true" | "false" | "grammar" | "spelling" | boolean | - |
Marks the choice as invalid (e.g. | ||
form | string | - |
| See the element or the Radix UI primitive this part renders. | ||
name | string | - |
| See the element or the Radix UI primitive this part renders. | ||
dir | "ltr" | "rtl" | - |
| See the element or the Radix UI primitive this part renders. | ||
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
loop | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
required | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
orientation | "horizontal" | "vertical" | - |
| See the element or the Radix UI primitive this part renders. | ||
| Prop | Type | Default |
|---|---|---|
valueRequired | string | - |
Value reported to the group's | ||
labelRequired | ReactNode | - |
Card title. Also the accessible name of the radio (via | ||
description | ReactNode | - |
Text under the title. Also the radio's accessible description (via | ||
icon | ReactNode | - |
Line icon shown in a bordered box at the start of the card (sized automatically). Ignored with | ||
media | ReactNode | - |
Visual preview (a mini screenshot, an illustration) shown full-width above the label instead of the card layout: the preview frame gets the selected ring and the label sits beneath with the group's indicator. Use it for appearance / layout pickers. Decorative: the label names the option. | ||
children | ReactNode | - |
Extra non-interactive content under the description (a price, a | ||
disabled | boolean | - |
Makes this card unselectable (dimmed, | ||
aria-describedby | string | - |
Ids of extra describing elements (e.g. a note outside the card). Merged after the card's own | ||
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
checked | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
required | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
The type of one item of options.
| Key | Type | Role |
|---|---|---|
value | string | The value of the card. Required. It must be unique in the group. |
label | ReactNode | The title of the card. Required. |
description | ReactNode | The text below the title. |
icon | ReactNode | A line icon in a box at the start of the card. |
disabled | boolean | The user cannot select the card. |