Toggle Field

Button Card

Selection using rich card components with icons, titles, and descriptions.

stable
card selection rich visual

Interactive demo of button card field

Choose the plan that best fits your needs

/**
 * Button Card Field Demo - Interactive examples of button card fields
 */

import { Form, FormBuilder } from '@saastro/forms';

import { FormProvider } from '@/components/FormProvider';
import { TooltipProvider } from '@/components/ui/tooltip';

const config = FormBuilder.create('button-card-demo')
  .layout('manual')
  .columns(12)
  .addField('plan', (f) =>
    f
      .type('button-card')
      .label('Select a Plan')
      .helperText('Choose the plan that best fits your needs')
      .buttonCardOptions([
        {
          value: 'free',
          title: 'Free',
          description: 'Perfect for getting started with basic features',
          icon: 'πŸ†“',
        },
        {
          value: 'pro',
          title: 'Pro',
          description: 'For growing teams who need more power',
          icon: '⭐',
          badge: 'Popular',
        },
        {
          value: 'enterprise',
          title: 'Enterprise',
          description: 'Custom solutions for large organizations',
          icon: '🏒',
        },
      ])
      .required('Please select a plan')
      .columns({ default: 12 }),
  )
  .addField('storage', (f) =>
    f
      .type('button-card')
      .label('Storage Option')
      .buttonCardOptions([
        {
          value: '10gb',
          title: '10 GB',
          description: 'Basic storage',
          icon: 'πŸ’Ύ',
        },
        {
          value: '100gb',
          title: '100 GB',
          description: 'Standard storage',
          icon: 'πŸ’Ώ',
        },
        {
          value: 'unlimited',
          title: 'Unlimited',
          description: 'No limits',
          icon: '☁️',
          badge: 'Best Value',
        },
      ])
      .required()
      .columns({ default: 12 }),
  )
  .addStep('main', ['plan', 'storage'])
  .build();

export default function ButtonCardDemo() {
  const handleSubmit = (data: Record<string, unknown>) => {
    console.log('Form submitted:', data);
    alert('Form submitted! Check console for data.');
  };

  return (
    <TooltipProvider>
      <FormProvider>
        <Form config={config} onSubmit={handleSubmit} className="space-y-6" />
      </FormProvider>
    </TooltipProvider>
  );
}

Overview

The button card field presents options as rich, card-style buttons. Each option pairs a bold title (text) with an optional description and icon. Ideal for plan selection, feature comparison, or any choice where visual hierarchy helps users decide.

By default a button card is single-select (the value is a string). Set multiple: true to turn the cards into toggles and collect a string[].

Usage

Basic Button Card

import { FormBuilder } from '@saastro/forms';
import { z } from 'zod';

const config = FormBuilder.create('form')
  .addField('plan', (f) =>
    f
      .type('button-card')
      .label('Select a Plan')
      .buttonCardOptions([
        {
          value: 'free',
          text: 'Free',
          description: 'Perfect for getting started',
          icon: 'lucide:gift',
        },
        {
          value: 'pro',
          text: 'Pro',
          description: 'For growing teams',
          icon: 'lucide:star',
        },
        {
          value: 'enterprise',
          text: 'Enterprise',
          description: 'Custom solutions',
          icon: 'lucide:building-2',
        },
      ])
      .schema(z.string().min(1, 'Please select a plan')),
  )
  .addStep('main', ['plan'])
  .build();

Single-select button cards hold a string value, so validate them with a Zod string schema as above β€” see Validation for why .required() doesn’t fit this field type.

Multi-select Cards

Multi-select is enabled by the multiple flag (.multiple(true) in the builder). The field value then becomes a string[], and clicking a card toggles it in and out of the array. Always pair it with .value([]) β€” the implicit initial value is '', not an empty array:

.addField('features', (f) =>
  f.type('button-card')
    .label('Select Features')
    .buttonCardOptions([
      { value: 'analytics', text: 'Analytics', description: 'Track metrics', icon: 'lucide:bar-chart-3' },
      { value: 'api', text: 'API Access', description: 'REST & GraphQL', icon: 'lucide:plug' },
      { value: 'support', text: 'Priority Support', description: '24/7 help', icon: 'lucide:message-circle' },
    ])
    .multiple(true)
    .value([]) // start with an empty array
    .optional()
)

JSON Configuration

{
  "type": "button-card",
  "label": "Select a Plan",
  "options": [
    {
      "value": "free",
      "text": "Free",
      "description": "Perfect for getting started",
      "icon": "lucide:gift"
    },
    {
      "value": "pro",
      "text": "Pro",
      "description": "For growing teams",
      "icon": "lucide:star"
    }
  ]
}

For multi-select in JSON, add "multiple": true at the field level, start the value at an empty array, and validate with minItems:

{
  "type": "button-card",
  "label": "Select Features",
  "multiple": true,
  "value": [],
  "options": [
    { "value": "analytics", "text": "Analytics", "icon": "lucide:bar-chart-3" },
    { "value": "api", "text": "API Access", "icon": "lucide:plug" }
  ],
  "schema": { "minItems": 1, "minItemsMessage": "Select at least one feature" }
}

Props

PropTypeDefaultDescription
type'button-card'-Field type (required)
labelstring-Group label
helperTextstring-Help text below field
optionsButtonCardOption[]-Array of card options
multiplebooleanfalseMulti-select mode β€” value becomes string[]
cardLayout'horizontal' | 'vertical''horizontal''vertical' stacks icon above text, centered β€” ideal for logo pickers (<img> as icon)
valuestring | string[]-Initial value. Set [] when multiple is true (the implicit default is '')
optionsClassNamestring-CSS classes for the cards container (default grid grid-cols-1 gap-2)
columnsPartial<Record<Breakpoint, number>>-Grid columns the field spans, by breakpoint
disabledboolean | function | ConditionGroupfalseDisable all cards
hiddenboolean | function | ConditionGroup | ResponsivefalseHide the field

ButtonCardOption Object

PropertyTypeDescription
valuestringSelection value (required)
textstringCard title (required)
descriptionstringOptional card description
iconReactNodeOptional icon: an Iconify icon name (e.g. 'lucide:star') or a React element
iconPropsRecord<string, unknown>Optional props forwarded to the icon (e.g. className)

Individual cards cannot be disabled β€” the disabled prop applies to the whole field.

Validation

Validation depends on the mode. Serializable rules (ValidationRules β€” what .required() and JSON "schema" objects produce) compile button-card to an array schema, which matches the string[] value of multi-select mode but not the string value of single-select mode. Use a Zod schema for single-select, and minItems for multi-select. See the validation guide for the full rules reference.

Required Selection (single-select)

Pass a Zod schema directly:

import { z } from 'zod';

.addField('tier', (f) =>
  f.type('button-card')
    .label('Select Tier')
    .buttonCardOptions([...])
    .schema(z.string().min(1, 'Please select a tier'))
)

At Least One Card (multi-select)

.addField('features', (f) =>
  f.type('button-card')
    .label('Select Features')
    .buttonCardOptions([...])
    .multiple(true)
    .value([])
    .itemCount(1) // error message: "Select at least 1"
)

In JSON, use "schema": { "minItems": 1, "minItemsMessage": "Select at least one" }.

Styling

Custom Classes

.addField('plan', (f) =>
  f.type('button-card')
    .label('Select a Plan')
    .buttonCardOptions([...])
    .schema(z.string().min(1, 'Please select a plan'))
    .classNames({
      wrapper: 'bg-muted/30 p-4 rounded-lg',
      label: 'text-lg font-semibold mb-3',
      error: 'text-destructive text-sm',
    })
)

Cards Layout with optionsClassName

Control card arrangement:

{
  "type": "button-card",
  "label": "Select Plan",
  "optionsClassName": "grid grid-cols-1 md:grid-cols-3 gap-4",
  "options": [...]
}

Responsive Layout

.addField('plan', (f) =>
  f.type('button-card')
    .label('Select a Plan')
    .buttonCardOptions([...])
    .schema(z.string().min(1, 'Please select a plan'))
    .columns({ default: 12, md: 8 })
)

JSON Styling

{
  "type": "button-card",
  "label": "Select Plan",
  "wrapper_className": "bg-muted/30 p-6 rounded-xl",
  "label_className": "text-xl font-bold",
  "optionsClassName": "grid grid-cols-1 md:grid-cols-3 gap-6",
  "columns": { "default": 12 }
}

Accessibility

  • Keyboard navigation between cards
  • Focus visible states
  • Selected state communicated via styling and button variants