---
title: "ChoiceCard"
description: "A question card answered with rich rows, optionally split into steps."
category: AiChat
status: beta
since: 0.6.0
tags: [ai, choice, options, guide, steps]
syncStatus: reviewed
verified: true
---

## Installation

:::TuffCodeBlock{lang="bash"}
---
code: |
  pnpm add @talex-touch/tuffex
---
:::

:::TuffCodeBlock{lang="typescript"}
---
code: |
  import { TxChoiceCard } from '@talex-touch/tuffex/choice-card'
  import '@talex-touch/tuffex/choice-card/style.css'
  // It renders TxIcon and TxSkeleton, whose sheets are separate
  import '@talex-touch/tuffex/icon/style.css'
  import '@talex-touch/tuffex/skeleton/style.css'
  import '@talex-touch/tuffex/base.css' // tokens + resets, once per app
---
:::

## Usage

### Single Question
`selected` marks the picked row; a `disabled` row can't be picked.
:::TuffDemoWrapper{demo="ChoiceCardSingleDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { ChoiceSelectPayload, ChoiceStep } from '@talex-touch/tuffex/choice-card'
  import { ref } from 'vue'

  const steps: ChoiceStep[] = [{
    id: 'start',
    title: 'Where should we start?',
    options: [
      { id: 'report', label: 'Write a weekly report', description: 'Sum up what shipped and what is next', icon: 'i-carbon-report' },
      { id: 'plan', label: 'Plan tomorrow', description: 'Three priorities, in order', icon: 'i-carbon-calendar' },
      { id: 'calendar', label: 'Sync my calendar', description: 'Connect a calendar account first', icon: 'i-carbon-calendar-add-alt', disabled: true },
    ],
  }]

  const selected = ref<string>()

  function onSelect({ option }: ChoiceSelectPayload) {
    selected.value = option.id
  }
  </script>

  <template>
    <TxChoiceCard :steps="steps" :selected="selected" @select="onSelect" />
  </template>
---
:::

### Steps
The host pages by updating `v-model:step` in `select`; `columns="2"` lays the rows out two per row.
:::TuffDemoWrapper{demo="ChoiceCardStepsDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { ChoiceSelectPayload, ChoiceStep } from '@talex-touch/tuffex/choice-card'
  import { computed, ref } from 'vue'

  const step = ref(0)
  const kind = ref<string>()
  const task = ref<string>()

  const kinds: ChoiceStep = {
    id: 'kind',
    title: 'What should we start with?',
    options: [
      { id: 'write', label: 'Write', description: 'Reports, emails, meeting notes', icon: 'i-carbon-pen' },
      { id: 'plan', label: 'Plan', description: 'Schedules, priorities, reviews', icon: 'i-carbon-roadmap' },
      // …learn, organize
    ],
  }

  // The second page follows the first answer: a new kind gives it a new id.
  const steps = computed<ChoiceStep[]>(() => kind.value
    ? [kinds, { id: `tasks-${kind.value}`, title: TITLES[kind.value], options: TASKS[kind.value] }]
    : [kinds])

  // Each page marks its own answer.
  const selected = computed(() => (step.value === 0 ? kind.value : task.value))

  function onSelect({ stepIndex, option }: ChoiceSelectPayload) {
    if (stepIndex === 0) {
      if (kind.value !== option.id)
        task.value = undefined
      kind.value = option.id
      step.value = 1 // the card never advances by itself
    }
    else {
      task.value = option.id
    }
  }
  </script>

  <template>
    <TxChoiceCard
      v-model:step="step"
      :steps="steps"
      :selected="selected"
      :columns="2"
      @select="onSelect"
    />
  </template>
---
:::

### Loading
`loading` draws skeleton rows in the rows' own boxes, so nothing shifts when the options arrive.
:::TuffDemoWrapper{demo="ChoiceCardLoadingDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { ChoiceOption, ChoiceStep } from '@talex-touch/tuffex/choice-card'
  import { useDeferredLoading } from '@talex-touch/tuffex/skeleton'
  import { computed, ref } from 'vue'

  const pending = ref(true)
  const picks = ref<ChoiceOption[]>([])
  // Nothing for 150ms, then at least 400ms: a fast answer never flashes a skeleton.
  const skeleton = useDeferredLoading(pending)

  const steps = computed<ChoiceStep[]>(() => [{ id: 'for-you', title: 'Ready for you', options: picks.value }])

  fetchPicks().then((options) => {
    picks.value = options
    pending.value = false
  })
  </script>

  <template>
    <TxChoiceCard :steps="steps" :loading="skeleton" :loading-rows="3" />
  </template>
---
:::

### Best Practices

- Ask one question per step, with three to six answers.
- Page by updating `v-model:step` in `select`, and pass `selected` for the page on screen so going back shows the pick.
- Bind `loading` through `useDeferredLoading`, and set `loadingRows` to the number of answers coming.
- Keep a row that needs something first disabled rather than hidden, and say what it needs in its description.
- Pass `prevLabel`, `nextLabel`, and `stepLabel` in the page's language; the card ships English defaults only.

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `steps` | `ChoiceStep[]` | - | The pages. Required; when empty and not loading, nothing renders. |
| `step` | `number` | `undefined` | Current page, 0-based, for `v-model:step`; clamped to the pages there are. |
| `selected` | `string` | `undefined` | Id of the answer to mark on the page on screen. |
| `loading` | `boolean` | `false` | Replaces the rows with skeleton rows and sets `aria-busy`; the title and pager stay. |
| `loadingRows` | `number` | `3` | How many skeleton rows `loading` draws. |
| `columns` | `1 \| 2` | `1` | `2` lays rows out two per row; below a 480px card it falls back to one. |
| `appear` | `boolean` | `true` | Rows rise in one after another on first render and when `loading` ends. |
| `prevLabel` | `string` | `'Previous'` | Accessible name of the back arrow. |
| `nextLabel` | `string` | `'Next'` | Accessible name of the forward arrow. |
| `stepLabel` | `(current: number, total: number) => string` | `` `${current} / ${total}` `` | Text of the pager counter; both numbers are 1-based. |

### Events

| Event | Params | Description |
|-------|--------|-------------|
| `select` | `(payload: ChoiceSelectPayload)` | Fires when an enabled row is clicked or activated with Enter or Space; the page stays. |
| `update:step` | `(index: number)` | Fires when an arrow moves to page `index`, 0-based. |

### Slots

| Slot | Props | Description |
|------|-------|-------------|
| `header` | `{ step: ChoiceStep, stepIndex: number, total: number }` | Replaces the default `<h3>` title; it names the card, so keep the question in it. |

### Types

`ChoiceStep`, one entry of `steps`:

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Page identity; a new id on the page on screen replays the page entrance. Required. |
| `title` | `string` | The question: the card's heading and the name of its answer list. Required. |
| `options` | `ChoiceOption[]` | The answers. Required. |

`ChoiceOption`, one answer:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | `string` | - | Unique within the page; what `selected` matches. Required. |
| `label` | `string` | - | The row's title and accessible name. Required. |
| `description` | `string` | - | One line under the label, announced as the row's description. |
| `icon` | `TxIconSource \| string` | - | A `TxIcon` source, or an icon class such as `'i-carbon-edit'`. |
| `disabled` | `boolean` | `false` | Dimmed; the arrow keys skip it and it emits nothing. |

:::TuffCodeBlock{lang="ts"}
---
code: |
  import type {
    ChoiceCardColumns, // 1 | 2
    ChoiceCardEmits,
    ChoiceCardProps,
    ChoiceOption,
    ChoiceSelectPayload, // { step, stepIndex, option }
    ChoiceStep,
    ChoiceStepLabelFormatter, // (current, total) => string
    TxChoiceCardInstance,
  } from '@talex-touch/tuffex/choice-card'
---
:::

### CSS Variables

| Variable | Description |
|----------|-------------|
| `--tx-choice-card-pad` | Inset between the card edge and the rows, default `8px`; the card radius grows with it. |
| `--tx-choice-card-option-radius` | Row corner radius, default `10px`; the card radius adds the inset, keeping corners concentric. |
| `--tx-choice-card-option-pad-x` | Horizontal inset of the rows and the title, default `12px`. |
| `--tx-choice-card-label-line` | Label line height and icon box height, default `20px`. |
| `--tx-choice-card-desc-line` | Description line height, default `18px`. |

Set them on the card or any ancestor; the component only writes `--tx-choice-card-index` (the row's stagger position) on each row.

## Overview

- Semantics: a `<section>` named by the question, with rows as native `<button>`s in a `<ul role="list">`, named by the label and described by the description. The selected row has a check and `aria-current="true"`, so colour is never the only mark.
- `select` never changes the page. Without `step`, the card tracks its own page and emits `update:step`; the pager renders only with two or more pages, and its counter is a `role="status"` region.
- Keyboard: the list is one Tab stop (the selected row, else the first enabled one); Up and Down move and wrap, Left and Right move in reading order in two columns, Home and End jump to the ends, and disabled rows are skipped.
- When the page changes with focus in the list, focus moves to the new page's Tab stop; when the arrow in use becomes disabled, focus moves to the other arrow.
- Two columns fall back to one below a 480px card (a container query), and the arrow keys follow the rendered column count.
- Entrance and page-change motion are CSS animations on fresh nodes, so rows are clickable from their first frame; reduced motion drops them.

## Technologies

- Skeleton rows reuse the loaded row's own containers, with `TxSkeleton` bars inside.
- Source: `packages/tuffex/packages/components/src/choice-card/`.

<TuffDocSourceLink />

## Use cases

- An assistant's opening guide: "Where should we start?", one question per step.
- A "Ready for you" list of suggestions that arrives after the card.
- Reviewing an answered question: `selected` marks what was picked.

## Related components

| Component | For |
|-----------|-----|
| [SuggestionChips](/docs/dev/components/suggestion-chips) | One-line follow-up prompts |
| [RecommendationCard](/docs/dev/components/recommendation-card) | One recommendation with alternatives |
| [ApprovalCard](/docs/dev/components/approval-card) | Multi-question answers, sent together |
