---
title: CheckboxSelect
description: A dropdown trigger styled like a text field that opens a panel of checkboxes, enabling multi-value selection.
---

```tsx
'use client';

import { useState } from 'react';
import { CheckboxSelect } from '@/ui/forms/CheckboxSelect';

const statusOptions = [
  { value: 'open', label: 'Open' },
  { value: 'fixed', label: 'Fixed' },
  { value: 'in-progress', label: 'In Progress' },
  { value: 'closed', label: 'Closed' },
];

export function CheckboxSelectDemo() {
  const [status, setStatus] = useState<string[]>(['open']);
  const [categories, setCategories] = useState<string[]>([]);

  return (
    <div className="space-y-24" style={{ minWidth: 300 }}>
      <CheckboxSelect
        label="Status"
        placeholder="Select statuses..."
        options={statusOptions}
        value={status}
        onChange={(_, v) => setStatus(v)}
      />

      <CheckboxSelect
        label="Category"
        placeholder="Select categories..."
        groups={[
          {
            label: 'Travel',
            options: [
              { value: 'flight', label: 'Flight' },
              { value: 'hotel', label: 'Hotel' },
            ],
          },
          {
            label: 'Activities',
            options: [
              { value: 'ski', label: 'Ski' },
              { value: 'golf', label: 'Golf' },
              { value: 'spa', label: 'Spa' },
            ],
          },
        ]}
        value={categories}
        onChange={(_, v) => setCategories(v)}
        renderValue={(vals) => `${vals.length} selected`}
      />

      <CheckboxSelect
        label="Priority"
        options={[
          { value: 'high', label: 'High' },
          { value: 'medium', label: 'Medium' },
          { value: 'low', label: 'Low' },
        ]}
        validationStatus="error"
        errorMessage="Please select at least one priority"
      />

      <CheckboxSelect label="Tags" options={statusOptions} value={['open']} disabled />
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/checkbox-select.json
```

Or manually, copy the following code into `/ui/forms/CheckboxSelect.tsx`:

```tsx
'use client';

import { useId, useState, useEffect, type ReactNode } from 'react';
import clsx from 'clsx';
import { Icon } from '@clubmed/trident-icons';
import { Checkbox } from './checkboxes/Checkbox';
import type { ValidationStatus } from '../hooks/useInternalStatus';
import { FormControl } from './FormControl';
import { useDropdown } from '../Dropdown';

function OptionLabel({ children, selected }: { children: ReactNode; selected: boolean }) {
  return (
    <span
      ref={(el) => {
        if (el) el.title = el.textContent ?? '';
      }}
      className={clsx('text-b3 text-black truncate', { 'font-semibold': selected })}
    >
      {children}
    </span>
  );
}

export interface CheckboxSelectOption<Value = string> {
  value: Value;
  label: ReactNode;
}

export interface CheckboxSelectGroup<Value = string> {
  label?: ReactNode;
  options: CheckboxSelectOption<Value>[];
}

export interface CheckboxSelectProps<Value = string> {
  id?: string;
  name?: string;
  label?: ReactNode;
  description?: string;
  placeholder?: string;
  value?: Value[];
  options?: CheckboxSelectOption<Value>[];
  groups?: CheckboxSelectGroup<Value>[];
  onChange?: (event: Event, value: Value[]) => void;
  disabled?: boolean;
  required?: boolean;
  hideRequiredStar?: boolean;
  validationStatus?: ValidationStatus;
  errorMessage?: string;
  className?: string;
  'data-testid'?: string;
  /** Renders the selected values summary in the trigger */
  renderValue?: (selected: Value[]) => ReactNode;
}

export function CheckboxSelect<Value = string>({
  id: idProp,
  name: nameProp,
  label,
  description,
  placeholder = 'Select...',
  value: controlledValue,
  options,
  groups,
  onChange,
  disabled = false,
  required = false,
  hideRequiredStar,
  validationStatus = 'default',
  errorMessage,
  className,
  'data-testid': dataTestId,
  renderValue,
}: CheckboxSelectProps<Value>) {
  const internalId = useId();
  const id = idProp ?? internalId;
  const name = nameProp ?? id;

  const { open, toggle, containerRef, isLeaving } = useDropdown();
  const [selected, setSelected] = useState<Value[]>(controlledValue ?? []);

  useEffect(() => {
    if (controlledValue !== undefined) {
      setSelected(controlledValue);
    }
  }, [controlledValue]);

  const toggleOption = (event: Event, val: Value) => {
    const next = selected.includes(val) ? selected.filter((v) => v !== val) : [...selected, val];
    setSelected(next);
    onChange?.(event, next);
  };

  const allOptions: CheckboxSelectOption<Value>[] = options
    ? options
    : (groups ?? []).flatMap((g) => g.options);

  const triggerLabel =
    selected.length === 0 ? (
      <span className="text-darkGrey">{placeholder}</span>
    ) : renderValue ? (
      renderValue(selected)
    ) : (
      <span>
        {allOptions
          .filter((o) => selected.includes(o.value))
          .map((o) => o.label)
          .join(', ')}
      </span>
    );

  const isError = validationStatus === 'error';
  const isSuccess = validationStatus === 'success';
  const isDisabled = disabled;

  const renderOptions = (opts: CheckboxSelectOption<Value>[]) => (
    <div className="flex flex-col gap-12">
      {opts.map((option, i) => (
        <Checkbox
          key={i}
          name={name}
          value={String(option.value)}
          checked={selected.includes(option.value)}
          disabled={disabled}
          onChange={(event) => toggleOption(event, option.value)}
        >
          <OptionLabel selected={selected.includes(option.value)}>{option.label}</OptionLabel>
        </Checkbox>
      ))}
    </div>
  );

  return (
    <FormControl
      id={id}
      label={label}
      description={description}
      className={className}
      dataName="CheckboxSelect"
      data-testid={dataTestId}
      disabled={disabled}
      required={required}
      hideRequiredStar={hideRequiredStar}
      validationStatus={validationStatus}
      errorMessage={errorMessage}
    >
      <div ref={containerRef} className="relative">
        <button
          type="button"
          id={id}
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-disabled={isDisabled}
          disabled={isDisabled}
          onClick={toggle}
          className={clsx(
            'text-b3 rounded-pill w-full inset-ring overflow-hidden px-20 py-12 font-normal outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 text-left flex items-center justify-between gap-8',
            {
              'bg-white text-black inset-ring-middleGrey focus:inset-ring-black active:inset-ring-black':
                !isDisabled && !isError && !isSuccess,
              'bg-pearl inset-ring-middleGrey text-grey': isDisabled,
              'inset-ring-red bg-white': isError,
              'inset-ring-green bg-white': isSuccess,
            },
          )}
        >
          <span className="truncate flex-1">{triggerLabel}</span>
          <span className="flex items-center gap-8 shrink-0">
            {isError && <Icon name="CrossDefault" width="24px" className="text-red" />}
            {isSuccess && <Icon name="CheckDefault" width="24px" className="text-green" />}
            <Icon
              name="ArrowDefaultDown"
              width="24px"
              className={clsx('text-black transition-transform', { 'rotate-180': open })}
            />
          </span>
        </button>

        <div
          role="listbox"
          aria-multiselectable="true"
          aria-hidden={!open && !isLeaving}
          className={clsx(
            'absolute z-10 top-[calc(100%+8px)] left-0 right-0 bg-white rounded-16 border border-lightGrey shadow-md p-12 origin-top will-change-[transform,opacity]',
            {
              'animate-zoom-in': open,
              'animate-zoom-out pointer-events-none': !open && isLeaving,
              'opacity-0 scale-90 pointer-events-none': !open && !isLeaving,
            },
          )}
        >
          {groups
            ? groups.map((group, gi) => (
                <div key={gi} className={clsx({ 'mt-16 first:mt-0': gi > 0 })}>
                  {group.label && (
                    <p className="text-b4 text-darkGrey font-semibold mb-8 ps-4">{group.label}</p>
                  )}
                  {renderOptions(group.options)}
                </div>
              ))
            : renderOptions(allOptions)}
        </div>
      </div>
    </FormControl>
  );
}
```

## Usage

```tsx showLineNumbers
import { CheckboxSelect } from '@/ui/forms/CheckboxSelect';

export function Component() {
  return (
    <CheckboxSelect
      label="Status"
      options={[
        { value: 'open', label: 'Open' },
        { value: 'fixed', label: 'Fixed' },
      ]}
    />
  );
}
```

## API Reference

### CheckboxSelect

| Prop              | Type                                    | Default        | Description                                                         |
| ----------------- | --------------------------------------- | -------------- | ------------------------------------------------------------------- |
| `label`           | `ReactNode`                             | `undefined`    | Label displayed above the trigger                                   |
| `description`     | `string`                                | `undefined`    | Secondary description shown next to the label                       |
| `placeholder`     | `string`                                | `"Select..."`  | Text shown in the trigger when no values are selected               |
| `options`         | `CheckboxSelectOption[]`                | `undefined`    | Flat list of options. Use `groups` instead for grouped options      |
| `groups`          | `CheckboxSelectGroup[]`                 | `undefined`    | Grouped options with optional group labels                          |
| `value`           | `Value[]`                               | `undefined`    | Controlled selected values                                          |
| `onChange`        | `(event: Event, value: Value[]) => void`| `undefined` | Called with event, updated selection when a checkbox is toggled |
| `renderValue`     | `(selected: Value[]) => ReactNode`      | `undefined`    | Custom renderer for the trigger label when values are selected      |
| `disabled`        | `boolean`                               | `false`        | Disables the trigger and all checkboxes                             |
| `required`        | `boolean`                               | `false`        | Marks the field as required                                         |
| `validationStatus`| `"default" \| "error" \| "success"`    | `"default"`    | Visual validation state of the trigger                              |
| `errorMessage`    | `string`                                | `undefined`    | Error text shown below when `validationStatus` is `"error"`         |
| `className`       | `string`                                | `undefined`    | Additional CSS classes on the outer wrapper                         |

### CheckboxSelectOption

| Prop    | Type        | Description              |
| ------- | ----------- | ------------------------ |
| `value` | `Value`     | The option's value       |
| `label` | `ReactNode` | Label shown in the panel |

### CheckboxSelectGroup

| Prop      | Type                       | Description                     |
| --------- | -------------------------- | ------------------------------- |
| `label`   | `ReactNode`                | Optional group heading           |
| `options` | `CheckboxSelectOption[]`   | Options within this group        |

## Notes

- The dropdown closes when clicking outside the component.
- Pass `groups` instead of `options` to render options under labeled sections.
- Use `renderValue` to customize the summary shown in the trigger (e.g. "3 selected").
- The trigger inherits the same border/pill styling as `TextField` and `Select`.
