All components
Radio Card
Single-choice cards with label, description, and radio indicator in surface, subtle, outline, and solid variants.
HobbyFor side projectsProFor production apps
12345678910111213141516171819202122import { RadioCard } from "#components/ui/base-ui/radio-card.tsx";import { indigoPalette } from "@stylexblocks/ui/themes/palettes.ts";
export function Example() { return ( // Root is Base UI's RadioGroup styled as cards; it mirrors the selected // value so each item can style itself from its checked state <RadioCard.Root defaultValue="pro" variant="outline" size="md" palette={indigoPalette} aria-label="Plan"> <RadioCard.Item value="pro"> <RadioCard.ItemControl> <RadioCard.ItemContent> <RadioCard.ItemText>Pro</RadioCard.ItemText> <RadioCard.ItemDescription>For production apps</RadioCard.ItemDescription> </RadioCard.ItemContent> <RadioCard.ItemIndicator /> </RadioCard.ItemControl> </RadioCard.Item> </RadioCard.Root> );}
// controlled: <RadioCard.Root value={plan} onValueChange={setPlan}>Variants
HobbyFor side projectsProFor production apps
surfaceHobbyFor side projectsProFor production apps
subtleHobbyFor side projectsProFor production apps
outlineHobbyFor side projectsProFor production apps
solidSizes
HobbyFor side projectsProFor production apps
smHobbyFor side projectsProFor production apps
mdHobbyFor side projectsProFor production apps
lgInstall
1npx stylexblocks add radio-cardSource
components/ui/base-ui/radio-card.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useState } from "react";import { Radio as BaseRadio } from "@base-ui-components/react/radio";import { RadioGroup as BaseRadioGroup } from "@base-ui-components/react/radio-group";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, fromMap, resolveSlotStyles, type SlotRecipeVariants,} from "#lib/recipe-props.ts";import { radioCardItemIndicatorDotVars } from '@stylexblocks/ui/slot-recipes/radio-card.stylex.ts'
import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { colors } from '@stylexblocks/ui/tokens/colors.stylex.ts'import { cursor } from '@stylexblocks/ui/tokens/cursor.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { radii } from '@stylexblocks/ui/tokens/radii.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const radioCardRoot = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._1_5, isolation: 'isolate', },})
const radioCardItem = stylex.create({ base: { flex: 1, display: 'flex', flexDirection: 'column', userSelect: 'none', position: 'relative', borderRadius: semanticRadii.panel, backgroundColor: { default: null, ':focus': `color-mix(in oklch, ${colorPalette.muted} 20%, transparent)`, }, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.8, }, borderColor: { default: null, ':is(:disabled, [disabled], [data-disabled])': semanticColors.borderSubtle, }, zIndex: { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': 1, }, },})
const radioCardItemSizes = stylex.create({ sm: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, md: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, lg: { fontSize: fontSizes.md, lineHeight: lineHeights.md, },})
const radioCardItemVariants = stylex.create({ surface: { borderWidth: '1px', backgroundColor: { default: null, ':focus': `color-mix(in oklch, ${colorPalette.muted} 20%, transparent)`, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.subtle, }, color: { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.fg, }, borderColor: { default: null, ':is(:disabled, [disabled], [data-disabled])': semanticColors.borderSubtle, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.muted, }, }, subtle: { backgroundColor: { default: semanticColors.bgMuted, ':focus': `color-mix(in oklch, ${colorPalette.muted} 20%, transparent)`, }, }, outline: { borderWidth: '1px', boxShadow: { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': '0 0 0 1px var(--shadow-color)', }, '--shadow-color': { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, borderColor: { default: null, ':is(:disabled, [disabled], [data-disabled])': semanticColors.borderSubtle, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, }, solid: { borderWidth: '1px', backgroundColor: { default: null, ':focus': `color-mix(in oklch, ${colorPalette.muted} 20%, transparent)`, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, color: { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.contrast, }, borderColor: { default: null, ':is(:disabled, [disabled], [data-disabled])': semanticColors.borderSubtle, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, },})
const radioCardItemJustifys = stylex.create({ start: { '--radio-card-justify': 'flex-start', }, end: { '--radio-card-justify': 'flex-end', }, center: { '--radio-card-justify': 'center', },})
const radioCardItemAligns = stylex.create({ start: { '--radio-card-align': 'flex-start', }, end: { '--radio-card-align': 'flex-end', }, center: { '--radio-card-align': 'center', },})
const radioCardLabel = stylex.create({ base: { display: 'inline-flex', fontWeight: fontWeights.medium, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, },})
const radioCardItemText = stylex.create({ base: { fontWeight: fontWeights.medium, },})
const radioCardItemDescription = stylex.create({ base: { opacity: 0.64, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, },})
const radioCardItemControl = stylex.create({ base: { display: 'inline-flex', flex: 1, position: 'relative', borderRadius: 'inherit', justifyContent: 'var(--radio-card-justify)', alignItems: 'var(--radio-card-align)', backgroundColor: { default: null, ':is(:disabled, [disabled], [data-disabled])': semanticColors.bgMuted, }, },})
const radioCardItemControlSizes = stylex.create({ sm: { padding: spacing._3, gap: spacing._1_5, }, md: { padding: spacing._4, gap: spacing._2_5, }, lg: { padding: spacing._4, gap: spacing._3_5, },})
const radioCardItemControlVariants = stylex.create({ subtle: { backgroundColor: { default: null, ':is(:disabled, [disabled], [data-disabled])': semanticColors.bgMuted, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.muted, }, color: { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.fg, }, },})
const radioCardItemControlAligns = stylex.create({ start: { textAlign: 'start', }, end: { textAlign: 'end', }, center: { textAlign: 'center', },})
const radioCardItemControlOrientations = stylex.create({ vertical: { flexDirection: 'column', }, horizontal: { flexDirection: 'row', },})
const radioCardItemIndicator = stylex.create({ base: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, verticalAlign: 'top', color: colors.white, borderWidth: '1px', borderColor: { default: 'transparent', ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderDestructive, }, borderRadius: radii.full, cursor: { default: cursor.radio, ':is(:disabled, [disabled], [data-disabled])': cursor.disabled, }, outlineWidth: { default: null, ':focus-visible': '1px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, [radioCardItemIndicatorDotVars.height]: '100%', [radioCardItemIndicatorDotVars.width]: '100%', [radioCardItemIndicatorDotVars.borderRadius]: radii.full, [radioCardItemIndicatorDotVars.backgroundColor]: 'currentColor', [radioCardItemIndicatorDotVars.scale]: 0.4, },})
const radioCardItemIndicatorSizes = stylex.create({ sm: { width: sizes._3_5, height: sizes._3_5, }, md: { width: sizes._4, height: sizes._4, }, lg: { width: sizes._5, height: sizes._5, },})
const radioCardItemIndicatorVariants = stylex.create({ surface: { borderWidth: '1px', borderColor: { default: semanticColors.border, ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderDestructive, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, backgroundColor: { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, color: { default: colors.white, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.contrast, }, }, subtle: { borderWidth: '1px', borderColor: { default: 'inherit', ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderDestructive, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, color: { default: colors.white, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.fg, }, [radioCardItemIndicatorDotVars.scale]: 0.6, }, outline: { borderWidth: '1px', borderColor: { default: semanticColors.border, ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderDestructive, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, backgroundColor: { default: null, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, color: { default: colors.white, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.contrast, }, }, solid: { backgroundColor: semanticColors.bg, borderWidth: '1px', borderColor: { default: 'inherit', ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderDestructive, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': 'currentcolor', }, color: { default: colors.white, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': colorPalette.solid, }, },})
const radioCardItemIndicatorDot = stylex.create({ base: { height: radioCardItemIndicatorDotVars.height, width: radioCardItemIndicatorDotVars.width, borderRadius: radioCardItemIndicatorDotVars.borderRadius, backgroundColor: radioCardItemIndicatorDotVars.backgroundColor, scale: radioCardItemIndicatorDotVars.scale, },})
const radioCardItemAddon = stylex.create({ base: { roundedBottom: 'inherit', color: { default: null, ':is(:disabled, [disabled], [data-disabled])': semanticColors.fgMuted, }, },})
const radioCardItemAddonSizes = stylex.create({ sm: { paddingInline: spacing._3, paddingBlock: spacing._1_5, borderTopWidth: '1px', }, md: { paddingInline: spacing._4, paddingBlock: spacing._2, borderTopWidth: '1px', }, lg: { paddingInline: spacing._4, paddingBlock: spacing._2, borderTopWidth: '1px', },})
const radioCardItemContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', flex: 1, gap: spacing._1, justifyContent: 'var(--radio-card-justify)', alignItems: 'var(--radio-card-align)', },})
export type RadioCardSize = keyof typeof radioCardItemSizes
export type RadioCardVariant = keyof typeof radioCardItemVariants
export type RadioCardJustify = keyof typeof radioCardItemJustifys
export type RadioCardAlign = keyof typeof radioCardItemAligns
export type RadioCardOrientation = keyof typeof radioCardItemControlOrientations
const radioCardRecipe = defineSlotRecipe({ slots: { root: { styles: radioCardRoot }, item: { styles: radioCardItem, sizes: radioCardItemSizes, variants: radioCardItemVariants, justifys: radioCardItemJustifys, aligns: radioCardItemAligns, }, itemText: { styles: radioCardItemText }, itemDescription: { styles: radioCardItemDescription }, itemControl: { styles: radioCardItemControl, sizes: radioCardItemControlSizes, variants: radioCardItemControlVariants, aligns: radioCardItemControlAligns, orientations: radioCardItemControlOrientations, }, itemIndicator: { styles: radioCardItemIndicator, sizes: radioCardItemIndicatorSizes, variants: radioCardItemIndicatorVariants, }, itemContent: { styles: radioCardItemContent }, }, defaultVariants: { size: "md", variant: "outline", align: "start", justify: "start", orientation: "horizontal", },});
const cardChecked = stylex.create({ surface: { backgroundColor: { default: null, ":is([data-checked])": colorPalette.subtle }, color: { default: null, ":is([data-checked])": colorPalette.fg }, borderColor: { default: null, ":is([data-checked])": colorPalette.muted }, }, subtle: { backgroundColor: { default: null, ":is([data-checked])": colorPalette.muted }, color: { default: null, ":is([data-checked])": colorPalette.fg }, }, outline: { boxShadow: { default: null, ":is([data-checked])": `0 0 0 1px ${colorPalette.solid}` }, borderColor: { default: null, ":is([data-checked])": colorPalette.solid }, }, solid: { backgroundColor: { default: null, ":is([data-checked])": colorPalette.solid }, color: { default: null, ":is([data-checked])": colorPalette.contrast }, borderColor: { default: null, ":is([data-checked])": colorPalette.solid }, },});
const cardIndicatorChecked = stylex.create({ surface: { backgroundColor: { default: null, ":is([data-checked])": colorPalette.solid }, color: { default: null, ":is([data-checked])": colorPalette.contrast }, borderColor: { default: null, ":is([data-checked])": colorPalette.solid }, }, subtle: { color: { default: null, ":is([data-checked])": colorPalette.fg }, borderColor: { default: null, ":is([data-checked])": colorPalette.solid }, }, outline: { backgroundColor: { default: null, ":is([data-checked])": colorPalette.solid }, color: { default: null, ":is([data-checked])": colorPalette.contrast }, borderColor: { default: null, ":is([data-checked])": colorPalette.solid }, }, solid: { color: { default: null, ":is([data-checked])": colorPalette.solid }, borderColor: { default: null, ":is([data-checked])": "currentcolor" }, },});
const chrome = stylex.create({ cursorPointer: { cursor: "pointer" },});
interface RadioCardContextValue { value: string | null;}
const CardContext = createContext<RadioCardContextValue>({ value: null });
const ItemContext = createContext<{ checked: boolean }>({ checked: false });
function RootNode({ value, defaultValue, onValueChange, ...props}: Omit<BaseRadioGroup.Props, "value" | "defaultValue"> & { value?: string | null; defaultValue?: string | null;}) { const [inner, setInner] = useState<string | null>(defaultValue ?? null); const current = value !== undefined ? value : inner; return ( <CardContext.Provider value={{ value: current }}> <BaseRadioGroup {...props} value={current} onValueChange={(next, eventDetails) => { setInner(next as string | null); onValueChange?.(next, eventDetails); }} /> </CardContext.Provider> );}
function ItemNode({ value, ...props}: Omit<BaseRadio.Root.Props, "value"> & { value: string }) { const { value: selected } = useContext(CardContext); const checked = selected === value; return ( <ItemContext.Provider value={{ checked }}> <BaseRadio.Root {...props} value={value} data-checked={checked ? "" : undefined} /> </ItemContext.Provider> );}
function ItemIndicatorNode({ children, ...props}: React.ComponentProps<"span">) { const { checked } = useContext(ItemContext); return ( <span {...props} data-checked={checked ? "" : undefined}> {children ?? (checked ? <span {...stylex.props(radioCardItemIndicatorDot.base)} /> : null)} </span> );}
const { withProvider, withContext } = createSlotRecipeContext(radioCardRecipe);
export type RadioCardVariantProps = Partial<SlotRecipeVariants<typeof radioCardRecipe>>;
/** * Radio Card — Base UI's radio group styled as selectable cards. * `<RadioCard.Root variant size>` resolves the recipe once; every part * inherits it through context. Root mirrors the selected value so Item and * ItemIndicator can stamp `data-checked` for `:is([data-checked])` chrome. */export const RadioCard = { Root: withProvider(RootNode, "root", "RadioCardRoot"), Item: withContext( ItemNode, (v) => [ ...resolveSlotStyles(radioCardRecipe.slots.item, v), fromMap(cardChecked, v.variant), chrome.cursorPointer, ], "RadioCardItem", ), ItemControl: withContext("span", "itemControl", "RadioCardItemControl"), ItemContent: withContext("span", "itemContent", "RadioCardItemContent"), ItemText: withContext("span", "itemText", "RadioCardItemText"), ItemDescription: withContext("span", "itemDescription", "RadioCardItemDescription"), ItemIndicator: withContext( ItemIndicatorNode, (v) => [ ...resolveSlotStyles(radioCardRecipe.slots.itemIndicator, v), fromMap(cardIndicatorChecked, v.variant), ], "RadioCardItemIndicator", ),};
export type RadioCardRootProps = React.ComponentProps<typeof RadioCard.Root>;export type RadioCardItemProps = React.ComponentProps<typeof RadioCard.Item>;export type RadioCardItemControlProps = React.ComponentProps<typeof RadioCard.ItemControl>;export type RadioCardItemContentProps = React.ComponentProps<typeof RadioCard.ItemContent>;export type RadioCardItemTextProps = React.ComponentProps<typeof RadioCard.ItemText>;export type RadioCardItemDescriptionProps = React.ComponentProps<typeof RadioCard.ItemDescription>;export type RadioCardItemIndicatorProps = React.ComponentProps<typeof RadioCard.ItemIndicator>;