Skip to main content
Nexus Design System
GitHub

ChoiceCard

ChoiceCard is a clickable card for a checkbox or radio choice with a title and description. The nested Checkbox or RadioGroupItem stays the only control. For a simple inline label use Label, and for compact options use ChoiceRow.

'use client';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';

export default function ChoiceCardDefault() {
  return (
    <div className="nx:w-96 nx:max-w-full">
      <ChoiceCard htmlFor="choice-card-default" variant="bordered">
        <Checkbox
          id="choice-card-default"
          aria-labelledby="choice-card-default-title"
          aria-describedby="choice-card-default-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-default-title">
            Product updates
          </ChoiceCardTitle>
          <ChoiceCardDescription id="choice-card-default-description">
            News about features and improvements.
          </ChoiceCardDescription>
        </ChoiceCardContent>
      </ChoiceCard>
    </div>
  );
}

This example also needs: Checkbox.

Installation

npx nxds-cli@latest add choice-card

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';

Composition

ChoiceCard
└── ChoiceCardContent
    ├── ChoiceCardTitle
    └── ChoiceCardDescription
ChoiceCardContent
├── ChoiceCardTitle
└── ChoiceCardDescription

Examples

With radio group

Pick one plan from a RadioGroup of cards.

Plan
'use client';

import * as React from 'react';

import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  type ChoiceCardProps,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';
import { FieldLegend, FieldSet } from '@/components/nxds/components/field/field';
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/nxds/components/radio-group/radio-group';

type ChoiceCardVariant = ChoiceCardProps['variant'];

interface PlanRadioCardsProps {
  idPrefix?: string;
  variant?: ChoiceCardVariant;
}

function PlanRadioCards({
  idPrefix = 'choice-card-radio',
  variant,
}: PlanRadioCardsProps = {}) {
  const legendId = React.useId();
  const standardId = `${idPrefix}-standard`;
  const priorityId = `${idPrefix}-priority`;

  return (
    <FieldSet className="nx:w-96 nx:max-w-full">
      <FieldLegend id={legendId}>Plan</FieldLegend>
      <RadioGroup aria-labelledby={legendId}>
        <ChoiceCard htmlFor={standardId} variant={variant}>
          <RadioGroupItem
            id={standardId}
            value="standard"
            aria-labelledby={`${standardId}-title`}
            aria-describedby={`${standardId}-description`}
          />
          <ChoiceCardContent>
            <ChoiceCardTitle id={`${standardId}-title`}>
              Standard
            </ChoiceCardTitle>
            <ChoiceCardDescription id={`${standardId}-description`}>
              Best for most teams.
            </ChoiceCardDescription>
          </ChoiceCardContent>
        </ChoiceCard>
        <ChoiceCard htmlFor={priorityId} variant={variant}>
          <RadioGroupItem
            id={priorityId}
            value="priority"
            aria-labelledby={`${priorityId}-title`}
            aria-describedby={`${priorityId}-description`}
          />
          <ChoiceCardContent>
            <ChoiceCardTitle id={`${priorityId}-title`}>
              Priority
            </ChoiceCardTitle>
            <ChoiceCardDescription id={`${priorityId}-description`}>
              Faster response routing for urgent teams.
            </ChoiceCardDescription>
          </ChoiceCardContent>
        </ChoiceCard>
      </RadioGroup>
    </FieldSet>
  );
}

export default function ChoiceCardWithRadioGroup() {
  return <PlanRadioCards variant="bordered" />;
}

This example also needs: Field, RadioGroup.

Trailing control

Put the content first to move the checkbox to the trailing edge.

'use client';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  type ChoiceCardProps,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';

type ChoiceCardVariant = ChoiceCardProps['variant'];

interface CheckboxChoiceCardProps {
  defaultChecked?: boolean;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function CheckboxChoiceCard({
  defaultChecked,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: CheckboxChoiceCardProps) {
  const checkbox = (
    <Checkbox
      id={id}
      defaultChecked={defaultChecked}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <ChoiceCard htmlFor={id} variant={variant}>
      {trailing ? content : checkbox}
      {trailing ? checkbox : content}
    </ChoiceCard>
  );
}

export default function ChoiceCardTrailingControl() {
  return (
    <div className="nx:w-96 nx:max-w-full">
      <CheckboxChoiceCard
        id="choice-card-trailing"
        defaultChecked
        trailing
        variant="bordered"
        title="Billing notices"
        description="Invoices, receipts, and payment updates for every workspace seat in this account."
      />
    </div>
  );
}

Disabled

Disable the nested control to lock the choice and dim the card.

'use client';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  type ChoiceCardProps,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/nxds/components/radio-group/radio-group';

type ChoiceCardVariant = ChoiceCardProps['variant'];

interface CheckboxChoiceCardProps {
  defaultChecked?: boolean;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function CheckboxChoiceCard({
  defaultChecked,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: CheckboxChoiceCardProps) {
  const checkbox = (
    <Checkbox
      id={id}
      defaultChecked={defaultChecked}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <ChoiceCard htmlFor={id} variant={variant}>
      {trailing ? content : checkbox}
      {trailing ? checkbox : content}
    </ChoiceCard>
  );
}

interface RadioChoiceCardProps {
  defaultValue?: string;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function RadioChoiceCard({
  defaultValue,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: RadioChoiceCardProps) {
  const item = (
    <RadioGroupItem
      id={id}
      value={id}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <RadioGroup aria-label={title} defaultValue={defaultValue ?? id}>
      <ChoiceCard htmlFor={id} variant={variant}>
        {trailing ? content : item}
        {trailing ? item : content}
      </ChoiceCard>
    </RadioGroup>
  );
}

export default function ChoiceCardDisabled() {
  return (
    <div className="nx:grid nx:w-96 nx:max-w-full nx:gap-3">
      <CheckboxChoiceCard
        id="choice-card-disabled"
        disabled
        title="Locked notifications"
        description="This setting is managed by your workspace."
      />
      <CheckboxChoiceCard
        id="choice-card-disabled-checked"
        disabled
        defaultChecked
        title="Usage reports"
        description="Always on for workspace admins."
      />
      <CheckboxChoiceCard
        id="choice-card-disabled-invalid"
        disabled
        invalid
        title="Audit log export"
        description="Unavailable until your billing details are fixed."
      />
      <RadioChoiceCard
        id="choice-card-disabled-radio"
        disabled
        title="Enterprise plan"
        description="Contact sales to switch to this plan."
      />
    </div>
  );
}

This example also needs: RadioGroup.

Invalid

Set aria-invalid on the nested control to give the card an error border.

'use client';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  type ChoiceCardProps,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/nxds/components/radio-group/radio-group';

type ChoiceCardVariant = ChoiceCardProps['variant'];

interface CheckboxChoiceCardProps {
  defaultChecked?: boolean;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function CheckboxChoiceCard({
  defaultChecked,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: CheckboxChoiceCardProps) {
  const checkbox = (
    <Checkbox
      id={id}
      defaultChecked={defaultChecked}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <ChoiceCard htmlFor={id} variant={variant}>
      {trailing ? content : checkbox}
      {trailing ? checkbox : content}
    </ChoiceCard>
  );
}

interface RadioChoiceCardProps {
  defaultValue?: string;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function RadioChoiceCard({
  defaultValue,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: RadioChoiceCardProps) {
  const item = (
    <RadioGroupItem
      id={id}
      value={id}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <RadioGroup aria-label={title} defaultValue={defaultValue ?? id}>
      <ChoiceCard htmlFor={id} variant={variant}>
        {trailing ? content : item}
        {trailing ? item : content}
      </ChoiceCard>
    </RadioGroup>
  );
}

export default function ChoiceCardInvalid() {
  return (
    <div className="nx:grid nx:w-96 nx:max-w-full nx:gap-3">
      <CheckboxChoiceCard
        id="choice-card-invalid-unselected"
        title="Product updates"
        description="News about features and improvements."
      />
      <CheckboxChoiceCard
        id="choice-card-invalid-selected"
        defaultChecked
        title="Security alerts"
        description="Sign-ins from new devices and password changes."
      />
      <CheckboxChoiceCard
        id="choice-card-invalid"
        defaultChecked
        invalid
        title="Required policy"
        description="Resolve this selection before continuing."
      />
      <RadioChoiceCard
        id="choice-card-invalid-radio"
        invalid
        title="Annual billing"
        description="Choose a billing period to continue."
      />
    </div>
  );
}

This example also needs: RadioGroup.

Indeterminate

Show a partly selected checkbox card with checked="indeterminate".

'use client';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  type ChoiceCardProps,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';

type ChoiceCardVariant = ChoiceCardProps['variant'];

interface CheckboxChoiceCardProps {
  defaultChecked?: boolean;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function CheckboxChoiceCard({
  defaultChecked,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: CheckboxChoiceCardProps) {
  const checkbox = (
    <Checkbox
      id={id}
      defaultChecked={defaultChecked}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <ChoiceCard htmlFor={id} variant={variant}>
      {trailing ? content : checkbox}
      {trailing ? checkbox : content}
    </ChoiceCard>
  );
}

export default function ChoiceCardIndeterminate() {
  return (
    <div className="nx:grid nx:w-96 nx:max-w-full nx:gap-3">
      <CheckboxChoiceCard
        id="choice-card-indeterminate-unselected"
        title="Marketing"
        description="Newsletters and event invitations."
      />
      <CheckboxChoiceCard
        id="choice-card-indeterminate-checked"
        defaultChecked
        title="Account"
        description="Billing receipts and security notices."
      />
      <ChoiceCard htmlFor="choice-card-indeterminate">
        <Checkbox
          id="choice-card-indeterminate"
          checked="indeterminate"
          aria-labelledby="choice-card-indeterminate-title"
          aria-describedby="choice-card-indeterminate-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-indeterminate-title">
            Product
          </ChoiceCardTitle>
          <ChoiceCardDescription id="choice-card-indeterminate-description">
            Some of these notifications are on.
          </ChoiceCardDescription>
        </ChoiceCardContent>
      </ChoiceCard>
    </div>
  );
}

All variants

Compare bordered and borderless cards for checkboxes and radios.

'use client';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  type ChoiceCardProps,
  ChoiceCardTitle,
} from '@/components/nxds/components/choice-card/choice-card';
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/nxds/components/radio-group/radio-group';

type ChoiceCardVariant = ChoiceCardProps['variant'];

interface CheckboxChoiceCardProps {
  defaultChecked?: boolean;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function CheckboxChoiceCard({
  defaultChecked,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: CheckboxChoiceCardProps) {
  const checkbox = (
    <Checkbox
      id={id}
      defaultChecked={defaultChecked}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <ChoiceCard htmlFor={id} variant={variant}>
      {trailing ? content : checkbox}
      {trailing ? checkbox : content}
    </ChoiceCard>
  );
}

interface RadioChoiceCardProps {
  defaultValue?: string;
  description: string;
  disabled?: boolean;
  id: string;
  invalid?: boolean;
  title: string;
  trailing?: boolean;
  variant?: ChoiceCardVariant;
}

function RadioChoiceCard({
  defaultValue,
  description,
  disabled,
  id,
  invalid,
  title,
  trailing,
  variant,
}: RadioChoiceCardProps) {
  const item = (
    <RadioGroupItem
      id={id}
      value={id}
      disabled={disabled}
      aria-invalid={invalid || undefined}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
    />
  );
  const content = (
    <ChoiceCardContent>
      <ChoiceCardTitle id={`${id}-title`}>{title}</ChoiceCardTitle>
      <ChoiceCardDescription id={`${id}-description`}>
        {description}
      </ChoiceCardDescription>
    </ChoiceCardContent>
  );

  return (
    <RadioGroup aria-label={title} defaultValue={defaultValue ?? id}>
      <ChoiceCard htmlFor={id} variant={variant}>
        {trailing ? content : item}
        {trailing ? item : content}
      </ChoiceCard>
    </RadioGroup>
  );
}

export default function ChoiceCardAllVariants() {
  return (
    <div className="nx:grid nx:w-[min(100%,56rem)] nx:grid-cols-1 nx:gap-6 nx:md:grid-cols-2">
      <div className="nx:grid nx:gap-3">
        <CheckboxChoiceCard
          id="choice-card-checkbox-bordered"
          title="Checkbox bordered"
          description="Default rich option card with a visible frame."
        />
        <CheckboxChoiceCard
          id="choice-card-checkbox-borderless"
          variant="borderless"
          title="Checkbox borderless"
          description="Quiet option card with no resting frame."
        />
      </div>
      <div className="nx:grid nx:gap-3">
        <RadioChoiceCard
          id="choice-card-radio-bordered"
          title="Radio bordered"
          description="Radio choices use the same card shell."
        />
        <RadioChoiceCard
          id="choice-card-radio-borderless"
          variant="borderless"
          trailing
          title="Radio borderless"
          description="Trailing controls use child order and top alignment."
        />
      </div>
    </div>
  );
}

This example also needs: RadioGroup.

Props

ChoiceCard

ChoiceCard props
PropTypeDefaultDescription
variant"borderless" | "bordered" | null"bordered"No description

ChoiceCardContent

ChoiceCardContent adds no props of its own; it accepts the props of the element or primitive it wraps.

ChoiceCardDescription

ChoiceCardDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

ChoiceCardTitle

ChoiceCardTitle adds no props of its own; it accepts the props of the element or primitive it wraps.

ChoiceCard is a clickable card for a checkbox or radio choice with a title and description. The nested Checkbox or RadioGroupItem stays the only control. For a simple inline label use Label, and for compact options use ChoiceRow.

<script setup lang="ts">
import { Checkbox } from '@/components/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/choice-card';
</script>

<template>
  <div class="nx:w-96 nx:max-w-full">
    <ChoiceCard for="choice-card-default" variant="bordered">
      <Checkbox
        id="choice-card-default"
        aria-labelledby="choice-card-default-title"
        aria-describedby="choice-card-default-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-default-title"
          >Product updates</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-default-description"
          >News about features and improvements.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
  </div>
</template>

This example also needs: Checkbox.

Installation

  1. Install these components first:

    Label.

  2. Install the following dependencies:

    npm install class-variance-authority@^0.7.1
    
  3. Copy and paste the following code into your project.

    components/choice-card/ChoiceCard.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import type { LabelProps as RekaLabelProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { Label } from '../label';
    
    import { type ChoiceCardVariants, choiceCardVariants } from '.';
    
    export interface ChoiceCardProps extends Omit<RekaLabelProps, 'asChild'> {
      variant?: ChoiceCardVariants['variant'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ChoiceCardProps>(), {
      variant: 'bordered',
    });
    
    const delegated = reactiveOmit(props, 'class', 'variant');
    </script>
    
    <template>
      <Label
        v-bind="delegated"
        data-slot="choice-card"
        :data-variant="variant"
        :class="cn(choiceCardVariants({ variant }), props.class)"
      >
        <slot />
      </Label>
    </template>
    
    components/choice-card/ChoiceCardContent.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ChoiceCardContentProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ChoiceCardContentProps>();
    </script>
    
    <template>
      <div
        data-slot="choice-card-content"
        :class="
          cn('nx:flex nx:min-w-0 nx:flex-1 nx:flex-col nx:gap-0.5', props.class)
        "
      >
        <slot />
      </div>
    </template>
    
    components/choice-card/ChoiceCardDescription.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ChoiceCardDescriptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ChoiceCardDescriptionProps>();
    </script>
    
    <template>
      <p
        data-slot="choice-card-description"
        :class="
          cn(
            'nx:m-0 nx:min-w-0 nx:break-words nx:typography-body-default nx:text-muted-foreground',
            'nx:group-has-[>[data-slot=checkbox]:disabled]/choice-card:text-disabled-foreground',
            'nx:group-has-[>[data-slot=radio-group-item]:disabled]/choice-card:text-disabled-foreground',
            props.class
          )
        "
      >
        <slot />
      </p>
    </template>
    
    components/choice-card/ChoiceCardTitle.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ChoiceCardTitleProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ChoiceCardTitleProps>();
    </script>
    
    <template>
      <div
        data-slot="choice-card-title"
        :class="
          cn(
            'nx:min-w-0 nx:break-words nx:typography-label-default nx:text-foreground',
            'nx:group-has-[>[data-slot=checkbox]:disabled]/choice-card:text-disabled-foreground',
            'nx:group-has-[>[data-slot=radio-group-item]:disabled]/choice-card:text-disabled-foreground',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/choice-card/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as ChoiceCard, type ChoiceCardProps } from './ChoiceCard.vue';
    export {
      default as ChoiceCardContent,
      type ChoiceCardContentProps,
    } from './ChoiceCardContent.vue';
    export {
      default as ChoiceCardDescription,
      type ChoiceCardDescriptionProps,
    } from './ChoiceCardDescription.vue';
    export {
      default as ChoiceCardTitle,
      type ChoiceCardTitleProps,
    } from './ChoiceCardTitle.vue';
    
    export const choiceCardVariants = cva(
      [
        'nx:group/choice-card nx:box-border nx:flex nx:min-w-0 nx:w-full nx:cursor-pointer nx:items-start nx:gap-3 nx:rounded-md nx:bg-background nx:p-4 nx:transition-colors',
        'nx:[&>[data-slot=checkbox]]:mt-0.5 nx:[&>[data-slot=radio-group-item]]:mt-0.5',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:hover:bg-background-hover',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:active:bg-background-active',
        'nx:has-[>[data-slot=checkbox]:disabled]:cursor-not-allowed nx:has-[>[data-slot=checkbox]:disabled]:border-border-disabled nx:has-[>[data-slot=checkbox]:disabled]:bg-disabled',
        'nx:has-[>[data-slot=radio-group-item]:disabled]:cursor-not-allowed nx:has-[>[data-slot=radio-group-item]:disabled]:border-border-disabled nx:has-[>[data-slot=radio-group-item]:disabled]:bg-disabled',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:has-[>[data-slot=checkbox][aria-invalid=true]]:border-error-border',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:has-[>[data-slot=radio-group-item][aria-invalid=true]]:border-error-border',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:not-has-[>[data-slot=checkbox][aria-invalid=true]]:not-has-[>[data-slot=radio-group-item][aria-invalid=true]]:has-[>[data-slot=checkbox][data-state=checked]]:border-primary-border',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:not-has-[>[data-slot=checkbox][aria-invalid=true]]:not-has-[>[data-slot=radio-group-item][aria-invalid=true]]:has-[>[data-slot=checkbox][data-state=checked]]:bg-primary-subtle',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:not-has-[>[data-slot=checkbox][aria-invalid=true]]:not-has-[>[data-slot=radio-group-item][aria-invalid=true]]:has-[>[data-slot=checkbox][data-state=indeterminate]]:border-primary-border',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:not-has-[>[data-slot=checkbox][aria-invalid=true]]:not-has-[>[data-slot=radio-group-item][aria-invalid=true]]:has-[>[data-slot=checkbox][data-state=indeterminate]]:bg-primary-subtle',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:not-has-[>[data-slot=checkbox][aria-invalid=true]]:not-has-[>[data-slot=radio-group-item][aria-invalid=true]]:has-[>[data-slot=radio-group-item][data-state=checked]]:border-primary-border',
        'nx:not-has-[>[data-slot=checkbox]:disabled]:not-has-[>[data-slot=radio-group-item]:disabled]:not-has-[>[data-slot=checkbox][aria-invalid=true]]:not-has-[>[data-slot=radio-group-item][aria-invalid=true]]:has-[>[data-slot=radio-group-item][data-state=checked]]:bg-primary-subtle',
      ],
      {
        variants: {
          variant: {
            bordered: 'nx:border-default nx:border-border-default',
            borderless: 'nx:border-default nx:border-transparent',
          },
        },
        defaultVariants: {
          variant: 'bordered',
        },
      }
    );
    
    export type ChoiceCardVariants = VariantProps<typeof choiceCardVariants>;
    
  4. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { ChoiceCard, ChoiceCardContent, ChoiceCardDescription, ChoiceCardTitle } from '@/components/choice-card';

Examples

With radio group

Pick one plan from a RadioGroup of cards.

<script setup lang="ts">
import { useId } from 'vue';

import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/choice-card';
import { FieldLegend, FieldSet } from '@/components/field';
import { RadioGroup, RadioGroupItem } from '@/components/radio-group';

const legendId = useId();
</script>

<template>
  <FieldSet class="nx:w-96 nx:max-w-full">
    <FieldLegend :id="legendId">Plan</FieldLegend>
    <RadioGroup :aria-labelledby="legendId">
      <ChoiceCard for="choice-card-radio-standard" variant="bordered">
        <RadioGroupItem
          id="choice-card-radio-standard"
          value="standard"
          aria-labelledby="choice-card-radio-standard-title"
          aria-describedby="choice-card-radio-standard-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-radio-standard-title"
            >Standard</ChoiceCardTitle
          >
          <ChoiceCardDescription id="choice-card-radio-standard-description"
            >Best for most teams.</ChoiceCardDescription
          >
        </ChoiceCardContent>
      </ChoiceCard>
      <ChoiceCard for="choice-card-radio-priority" variant="bordered">
        <RadioGroupItem
          id="choice-card-radio-priority"
          value="priority"
          aria-labelledby="choice-card-radio-priority-title"
          aria-describedby="choice-card-radio-priority-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-radio-priority-title"
            >Priority</ChoiceCardTitle
          >
          <ChoiceCardDescription id="choice-card-radio-priority-description"
            >Faster response routing for urgent teams.</ChoiceCardDescription
          >
        </ChoiceCardContent>
      </ChoiceCard>
    </RadioGroup>
  </FieldSet>
</template>

This example also needs: Field, RadioGroup.

Trailing control

Put the content first to move the checkbox to the trailing edge.

<script setup lang="ts">
import { Checkbox } from '@/components/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/choice-card';
</script>

<template>
  <div class="nx:w-96 nx:max-w-full">
    <ChoiceCard for="choice-card-trailing" variant="bordered">
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-trailing-title"
          >Billing notices</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-trailing-description"
          >Invoices, receipts, and payment updates for every workspace seat in
          this account.</ChoiceCardDescription
        >
      </ChoiceCardContent>
      <Checkbox
        id="choice-card-trailing"
        :default-value="true"
        aria-labelledby="choice-card-trailing-title"
        aria-describedby="choice-card-trailing-description"
      />
    </ChoiceCard>
  </div>
</template>

Disabled

Disable the nested control to lock the choice and dim the card.

<script setup lang="ts">
import { Checkbox } from '@/components/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/choice-card';
import { RadioGroup, RadioGroupItem } from '@/components/radio-group';
</script>

<template>
  <div class="nx:grid nx:w-96 nx:max-w-full nx:gap-3">
    <ChoiceCard for="choice-card-disabled">
      <Checkbox
        id="choice-card-disabled"
        disabled
        aria-labelledby="choice-card-disabled-title"
        aria-describedby="choice-card-disabled-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-disabled-title"
          >Locked notifications</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-disabled-description"
          >This setting is managed by your workspace.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <ChoiceCard for="choice-card-disabled-checked">
      <Checkbox
        id="choice-card-disabled-checked"
        :default-value="true"
        disabled
        aria-labelledby="choice-card-disabled-checked-title"
        aria-describedby="choice-card-disabled-checked-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-disabled-checked-title"
          >Usage reports</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-disabled-checked-description"
          >Always on for workspace admins.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <ChoiceCard for="choice-card-disabled-invalid">
      <Checkbox
        id="choice-card-disabled-invalid"
        disabled
        aria-invalid="true"
        aria-labelledby="choice-card-disabled-invalid-title"
        aria-describedby="choice-card-disabled-invalid-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-disabled-invalid-title"
          >Audit log export</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-disabled-invalid-description"
          >Unavailable until your billing details are
          fixed.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <RadioGroup
      aria-label="Enterprise plan"
      default-value="choice-card-disabled-radio"
    >
      <ChoiceCard for="choice-card-disabled-radio">
        <RadioGroupItem
          id="choice-card-disabled-radio"
          value="choice-card-disabled-radio"
          disabled
          aria-labelledby="choice-card-disabled-radio-title"
          aria-describedby="choice-card-disabled-radio-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-disabled-radio-title"
            >Enterprise plan</ChoiceCardTitle
          >
          <ChoiceCardDescription id="choice-card-disabled-radio-description"
            >Contact sales to switch to this plan.</ChoiceCardDescription
          >
        </ChoiceCardContent>
      </ChoiceCard>
    </RadioGroup>
  </div>
</template>

This example also needs: RadioGroup.

Invalid

Set aria-invalid on the nested control to give the card an error border.

<script setup lang="ts">
import { Checkbox } from '@/components/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/choice-card';
import { RadioGroup, RadioGroupItem } from '@/components/radio-group';
</script>

<template>
  <div class="nx:grid nx:w-96 nx:max-w-full nx:gap-3">
    <ChoiceCard for="choice-card-invalid-unselected">
      <Checkbox
        id="choice-card-invalid-unselected"
        aria-labelledby="choice-card-invalid-unselected-title"
        aria-describedby="choice-card-invalid-unselected-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-invalid-unselected-title"
          >Product updates</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-invalid-unselected-description"
          >News about features and improvements.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <ChoiceCard for="choice-card-invalid-selected">
      <Checkbox
        id="choice-card-invalid-selected"
        :default-value="true"
        aria-labelledby="choice-card-invalid-selected-title"
        aria-describedby="choice-card-invalid-selected-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-invalid-selected-title"
          >Security alerts</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-invalid-selected-description"
          >Sign-ins from new devices and password
          changes.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <ChoiceCard for="choice-card-invalid">
      <Checkbox
        id="choice-card-invalid"
        :default-value="true"
        aria-invalid="true"
        aria-labelledby="choice-card-invalid-title"
        aria-describedby="choice-card-invalid-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-invalid-title"
          >Required policy</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-invalid-description"
          >Resolve this selection before continuing.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <RadioGroup
      aria-label="Annual billing"
      default-value="choice-card-invalid-radio"
    >
      <ChoiceCard for="choice-card-invalid-radio">
        <RadioGroupItem
          id="choice-card-invalid-radio"
          value="choice-card-invalid-radio"
          aria-invalid="true"
          aria-labelledby="choice-card-invalid-radio-title"
          aria-describedby="choice-card-invalid-radio-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-invalid-radio-title"
            >Annual billing</ChoiceCardTitle
          >
          <ChoiceCardDescription id="choice-card-invalid-radio-description"
            >Choose a billing period to continue.</ChoiceCardDescription
          >
        </ChoiceCardContent>
      </ChoiceCard>
    </RadioGroup>
  </div>
</template>

This example also needs: RadioGroup.

Indeterminate

Show a partly selected checkbox card with model-value="indeterminate".

<script setup lang="ts">
import { Checkbox } from '@/components/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/choice-card';
</script>

<template>
  <div class="nx:grid nx:w-96 nx:max-w-full nx:gap-3">
    <ChoiceCard for="choice-card-indeterminate-unselected">
      <Checkbox
        id="choice-card-indeterminate-unselected"
        aria-labelledby="choice-card-indeterminate-unselected-title"
        aria-describedby="choice-card-indeterminate-unselected-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-indeterminate-unselected-title"
          >Marketing</ChoiceCardTitle
        >
        <ChoiceCardDescription
          id="choice-card-indeterminate-unselected-description"
          >Newsletters and event invitations.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <ChoiceCard for="choice-card-indeterminate-checked">
      <Checkbox
        id="choice-card-indeterminate-checked"
        :default-value="true"
        aria-labelledby="choice-card-indeterminate-checked-title"
        aria-describedby="choice-card-indeterminate-checked-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-indeterminate-checked-title"
          >Account</ChoiceCardTitle
        >
        <ChoiceCardDescription
          id="choice-card-indeterminate-checked-description"
          >Billing receipts and security notices.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
    <ChoiceCard for="choice-card-indeterminate">
      <Checkbox
        id="choice-card-indeterminate"
        model-value="indeterminate"
        aria-labelledby="choice-card-indeterminate-title"
        aria-describedby="choice-card-indeterminate-description"
      />
      <ChoiceCardContent>
        <ChoiceCardTitle id="choice-card-indeterminate-title"
          >Product</ChoiceCardTitle
        >
        <ChoiceCardDescription id="choice-card-indeterminate-description"
          >Some of these notifications are on.</ChoiceCardDescription
        >
      </ChoiceCardContent>
    </ChoiceCard>
  </div>
</template>

All variants

Compare bordered and borderless cards for checkboxes and radios.

<script setup lang="ts">
import { Checkbox } from '@/components/checkbox';
import {
  ChoiceCard,
  ChoiceCardContent,
  ChoiceCardDescription,
  ChoiceCardTitle,
} from '@/components/choice-card';
import { RadioGroup, RadioGroupItem } from '@/components/radio-group';
</script>

<template>
  <div
    class="nx:grid nx:w-[min(100%,56rem)] nx:grid-cols-1 nx:gap-6 nx:md:grid-cols-2"
  >
    <div class="nx:grid nx:gap-3">
      <ChoiceCard for="choice-card-checkbox-bordered">
        <Checkbox
          id="choice-card-checkbox-bordered"
          aria-labelledby="choice-card-checkbox-bordered-title"
          aria-describedby="choice-card-checkbox-bordered-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-checkbox-bordered-title"
            >Checkbox bordered</ChoiceCardTitle
          >
          <ChoiceCardDescription id="choice-card-checkbox-bordered-description"
            >Default rich option card with a visible
            frame.</ChoiceCardDescription
          >
        </ChoiceCardContent>
      </ChoiceCard>
      <ChoiceCard for="choice-card-checkbox-borderless" variant="borderless">
        <Checkbox
          id="choice-card-checkbox-borderless"
          aria-labelledby="choice-card-checkbox-borderless-title"
          aria-describedby="choice-card-checkbox-borderless-description"
        />
        <ChoiceCardContent>
          <ChoiceCardTitle id="choice-card-checkbox-borderless-title"
            >Checkbox borderless</ChoiceCardTitle
          >
          <ChoiceCardDescription
            id="choice-card-checkbox-borderless-description"
            >Quiet option card with no resting frame.</ChoiceCardDescription
          >
        </ChoiceCardContent>
      </ChoiceCard>
    </div>
    <div class="nx:grid nx:gap-3">
      <RadioGroup
        aria-label="Radio bordered"
        default-value="choice-card-radio-bordered"
      >
        <ChoiceCard for="choice-card-radio-bordered">
          <RadioGroupItem
            id="choice-card-radio-bordered"
            value="choice-card-radio-bordered"
            aria-labelledby="choice-card-radio-bordered-title"
            aria-describedby="choice-card-radio-bordered-description"
          />
          <ChoiceCardContent>
            <ChoiceCardTitle id="choice-card-radio-bordered-title"
              >Radio bordered</ChoiceCardTitle
            >
            <ChoiceCardDescription id="choice-card-radio-bordered-description"
              >Radio choices use the same card shell.</ChoiceCardDescription
            >
          </ChoiceCardContent>
        </ChoiceCard>
      </RadioGroup>
      <RadioGroup
        aria-label="Radio borderless"
        default-value="choice-card-radio-borderless"
      >
        <ChoiceCard for="choice-card-radio-borderless" variant="borderless">
          <ChoiceCardContent>
            <ChoiceCardTitle id="choice-card-radio-borderless-title"
              >Radio borderless</ChoiceCardTitle
            >
            <ChoiceCardDescription id="choice-card-radio-borderless-description"
              >Trailing controls use child order and top
              alignment.</ChoiceCardDescription
            >
          </ChoiceCardContent>
          <RadioGroupItem
            id="choice-card-radio-borderless"
            value="choice-card-radio-borderless"
            aria-labelledby="choice-card-radio-borderless-title"
            aria-describedby="choice-card-radio-borderless-description"
          />
        </ChoiceCard>
      </RadioGroup>
    </div>
  </div>
</template>

This example also needs: RadioGroup.

Props

ChoiceCard

ChoiceCard props
PropTypeDefaultDescription
variant"bordered" | "borderless" | null"bordered"No description

ChoiceCardContent

ChoiceCardContent adds no props of its own; it accepts the props of the element or primitive it wraps.

ChoiceCardDescription

ChoiceCardDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

ChoiceCardTitle

ChoiceCardTitle adds no props of its own; it accepts the props of the element or primitive it wraps.