---
title: CriteriaParticipants
description: Adult and child participant selector with three children-flow modes, per-child birthdate or age/month entry, animated transitions, participant chip summary, and an optional limit banner.
---

```tsx
'use client';
import { useState } from 'react';
import { subYears } from 'date-fns';

import { CriteriaParticipants } from '@/ui/criteria/CriteriaParticipants';
import type { CriteriaParticipantsAction } from '@/ui/criteria/CriteriaParticipants';

const labels = {
  addAdult: 'Add adult',
  addChild: 'Add child',
  adults: 'Adults',
  children: 'Children',
  childrenAgeLimit: 'Under 18 y.o.',
  childBirthdate: 'Child {n} birthdate',
  childBirthdateInvalidDateError: 'Invalid date',
  childBirthdateMaxAgeError: 'Too young (under 2)',
  childBirthdateMinAgeError: 'Too old (over 17)',
  removeAdult: 'Remove adult',
  removeChild: 'Remove child',
  enterDateOfBirth: 'Enter date of birth',
  ageQuestion: 'How old will the children be at check-in?',
  ageYear: 'Year',
  ageMonths: 'Months',
  ageApply: 'Apply',
  participantDetails: 'Participant Details',
  adultLabel: 'Adult {n}',
  childLabel: 'Child {n}',
  removeParticipant: 'Remove',
  reset: 'Reset',
  apply: 'Continue',
};

export function CriteriaParticipantsDemo() {
  const [adultsCount, setAdults] = useState(2);
  const [childrenCount, setChildren] = useState(0);
  const [birthdates, setBirthdates] = useState<Array<Date | null | undefined>>([]);
  const [childAges, setChildAges] = useState<
    Array<{ value: number; unit: 'year' | 'month' } | null>
  >([]);

  const handleChange = (action: CriteriaParticipantsAction) => {
    if (action.type === 'adultsCount') setAdults(action.payload);
    if (action.type === 'childrenCount') {
      setChildren(action.payload);
      setBirthdates((prev) =>
        action.payload > prev.length ? [...prev, null] : prev.slice(0, action.payload),
      );
      setChildAges((prev) =>
        action.payload > prev.length
          ? [...prev, ...Array(action.payload - prev.length).fill(null)]
          : prev.slice(0, action.payload),
      );
    }
    if (action.type === 'birthdate') {
      setBirthdates((prev) => {
        const next = [...prev];
        next[action.payload.index] = action.payload.date;
        return next;
      });
    }
    if (action.type === 'childAge') {
      setChildAges((prev) => {
        const next = [...prev];
        next[action.payload.index] =
          action.payload.value !== null
            ? { value: action.payload.value, unit: action.payload.unit }
            : null;
        return next;
      });
    }
  };

  return (
    <div className="w-[380px] space-y-24">
      <CriteriaParticipants
        labels={labels}
        adultsCount={adultsCount}
        childrenCount={childrenCount}
        birthdates={birthdates}
        childAges={childAges}
        maxAuthorizedBirthdate={subYears(new Date(), 2)}
        minAuthorizedBirthdate={subYears(new Date(), 17)}
        onChange={handleChange}
        title="Participant"
        onReset={() => {
          setAdults(2);
          setChildren(0);
          setBirthdates([]);
          setChildAges([]);
        }}
        onApply={() => {}}
      />
      <p className="text-b4">
        {adultsCount} adult{adultsCount !== 1 ? 's' : ''}, {childrenCount} child
        {childrenCount !== 1 ? 'ren' : ''}
      </p>
    </div>
  );
}
```

## Installation

Using the CLI:

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

Or manually, copy the following code into `/ui/criteria/CriteriaParticipants.tsx`:

```tsx
'use client';
import clsx from 'clsx';
import { differenceInMonths, differenceInYears, startOfToday } from 'date-fns';
import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type Dispatch,
  type FunctionComponent,
  type ReactNode,
} from 'react';

import { Icon } from '@clubmed/trident-icons';
import { Chip } from '../Chip';
import { ElasticHeight } from '../ElasticHeight';
import { Prose } from '../Prose';
import { Toggle } from '../Toggle';
import { Button } from '../buttons/Button';
import { NumberField } from '../forms/NumberField';
import { Switch } from '../forms/Switch';
import { Birthdate } from './Birthdate';

export interface ParticipantsLimitContact {
  description: string | null;
  href: string;
  icon: string;
  target: string;
  title: string;
}

export interface ParticipantsLimitLabels {
  expand: string;
  reduce: string;
}

export interface ParticipantsLimit {
  title: string;
  description: string;
  contacts: ParticipantsLimitContact[];
  labels: ParticipantsLimitLabels;
}

export interface CriteriaParticipantsLabels {
  addAdult: string;
  addChild: string;
  adults: string;
  adultsDescription?: string | null;
  children: string;
  childrenAgeLimit: string;
  childBirthdate: string;
  childBirthdateInvalidDateError: string;
  childBirthdateMaxAgeError: string;
  childBirthdateMinAgeError: string;
  limit?: ParticipantsLimit;
  removeAdult: string;
  removeChild: string;
  /** Label for the "Enter date of birth" toggle. When omitted the age entry section is hidden. */
  enterDateOfBirth?: string;
  /** Question shown above per-child age inputs in year mode */
  ageQuestion?: string;
  /** "Year" tab label in the year/months toggle */
  ageYear?: string;
  /** "Months" tab label in the year/months toggle */
  ageMonths?: string;
  /** "Apply" button label for confirming a child's age */
  ageApply?: string;
  /** "Participant Details" section title. When omitted the summary section is hidden. */
  participantDetails?: string;
  /** Template for adult chip labels – use {n} for 1-based index, e.g. "Adult {n}" */
  adultLabel?: string;
  /** Template for child chip labels – use {n} for 1-based index, e.g. "Child {n}" */
  childLabel?: string;
  /** aria-label for chip remove buttons */
  removeParticipant?: string;
  /** "Reset" link label */
  reset?: string;
  /** "Continue" button label */
  apply?: string;
}

export type CriteriaParticipantsAction =
  | { type: 'adultsCount'; payload: number }
  | { type: 'childrenCount'; payload: number }
  | { type: 'birthdate'; payload: { date: Date | null; index: number } }
  | { type: 'childAge'; payload: { index: number; value: number | null; unit: 'year' | 'month' } };

export interface CriteriaParticipantsProps {
  labels: CriteriaParticipantsLabels;
  title?: string;
  /** card (default): white outer card with sand inner. flat: no background, fields rendered directly */
  variant?: 'card' | 'flat';
  adultsCount: number;
  minAdults?: number;
  maxAdults?: number;
  withChildren?: boolean;
  childrenCount: number;
  minChildren?: number;
  maxChildren?: number;
  /** Total adults + children at which the limit banner appears (default 6) */
  maxTotalParticipants?: number;
  minAuthorizedBirthdate?: Date;
  maxAuthorizedBirthdate: Date;
  birthdates: Array<Date | null | undefined>;
  /** Applied child ages for year/month mode. One entry per child. */
  childAges?: Array<{ value: number; unit: 'year' | 'month' } | null | undefined>;
  className?: string;
  onChange: Dispatch<CriteriaParticipantsAction>;
  onLimitDisplay?: () => void;
  onReset?: () => void;
  onApply?: () => void;
  /** Controls the children age-entry UX and when onChange fires */
  childrenFlow?: 'birthdate-only' | 'age-or-birthdate' | 'age-or-birthdate-with-apply';
}

const DEFAULT_MAX_TOTAL = 6;

function interpolate(template: string, n: number): string {
  return template.replace('{n}', String(n));
}

function computeAgeLabel(
  birthdate: Date | null | undefined,
  childAge: { value: number; unit: 'year' | 'month' } | null | undefined,
  labels: CriteriaParticipantsLabels,
): ReactNode {
  if (childAge != null) {
    const unit =
      childAge.unit === 'year' ? labels.ageYear ?? 'years' : labels.ageMonths ?? 'months';
    return <span className="text-b5 font-normal">{`${childAge.value} ${unit}`}</span>;
  }
  if (birthdate != null) {
    const today = startOfToday();
    const years = differenceInYears(today, birthdate);
    if (years > 0) return `${years} ${labels.ageYear ?? 'years'}`;
    const months = differenceInMonths(today, birthdate);
    if (months > 0) return `${months} ${labels.ageMonths ?? 'months'}`;
  }
  return '';
}

const ParticipantNumberField: FunctionComponent<{
  label: string;
  description?: string | null;
  addLabel: string;
  removeLabel: string;
  min?: number;
  max?: number;
  value: number;
  onChange: (value: number) => void;
}> = ({ label, description, addLabel, removeLabel, min, max, value, onChange }) => (
  <NumberField
    controlled
    description={description ?? undefined}
    label={label}
    labels={{
      decrease: removeLabel,
      increase: addLabel,
      input: description ? `${label} (${description})` : label,
    }}
    max={max}
    min={min}
    onChange={(_, v) => onChange(v)}
    value={value}
    className="w-full justify-between"
  />
);

const closeIcon = <Icon aria-hidden name="CrossOutlined" width="20px" />;

const ParticipantChip: FunctionComponent<{
  label: string;
  ariaLabel?: string;
  onClick: () => void;
  type: 'adult' | 'child';
  ageLabel?: ReactNode;
}> = ({ label, ariaLabel, onClick, type, ageLabel }) => (
  <button
    aria-label={ariaLabel}
    className="rounded-32 border-black flex items-center justify-between gap-4 border px-12 py-6 text-b4 font-semibold"
    onClick={onClick}
    type="button"
  >
    <span className="flex items-center">
      {type === 'adult' ? (
        <Icon name="PeopleSingle" width="24px" />
      ) : (
        <Icon name="PeopleKids" width="24px" />
      )}
      {label}
      {ageLabel && <span className="ms-4 text-b5 font-normal">({ageLabel})</span>}
    </span>
    {closeIcon}
  </button>
);

const Footer: FunctionComponent<{
  onReset?: () => void;
  onApply?: () => void;
  resetLabel?: string;
  applyLabel?: string;
  withSpacer?: boolean;
}> = ({ onReset, onApply, resetLabel, applyLabel, withSpacer }) => {
  if (!onReset && !onApply) return null;
  return (
    <div className="flex items-center justify-between">
      {onReset && resetLabel ? (
        <button
          className="text-b3 cursor-pointer font-semibold underline"
          onClick={onReset}
          type="button"
        >
          {resetLabel}
        </button>
      ) : withSpacer ? (
        <span />
      ) : null}
      {onApply && applyLabel && (
        <Button color="saffron" onClick={onApply}>
          {applyLabel}
        </Button>
      )}
    </div>
  );
};

const LimitInformation: FunctionComponent<ParticipantsLimit> = ({
  title,
  description,
  contacts,
  labels,
}) => {
  const [expanded, setExpanded] = useState(false);
  return (
    <div className="rounded-16 border-lightGrey space-y-12 border bg-white p-12">
      <p className="text-b3 flex items-center gap-12 font-semibold">
        <Chip color="sienna" icon="Error" size="size-48" theme="solid" />
        {title}
      </p>
      <ElasticHeight isExpanded={expanded}>
        <div className="space-y-20 pb-4">
          <p>{description}</p>
          {contacts.length > 0 && (
            <div>
              {contacts.map(({ description, href, icon, target, title }) => (
                <a
                  key={title}
                  className="mb-20 flex gap-12"
                  href={href}
                  rel="noreferrer"
                  target={target}
                >
                  <Chip color="black" icon={icon as never} size="size-48" theme="outline" />
                  <div className="space-y-8 font-sans">
                    <p className="text-b3 font-semibold">{title}</p>
                    {description && <Prose className="text-b4" text={description} />}
                  </div>
                </a>
              ))}
            </div>
          )}
        </div>
      </ElasticHeight>
      <button
        className="text-b4 cursor-pointer underline"
        onClick={() => setExpanded((v) => !v)}
        type="button"
      >
        {expanded ? labels.reduce : labels.expand}
      </button>
    </div>
  );
};

export const CriteriaParticipants: FunctionComponent<CriteriaParticipantsProps> = ({
  adultsCount,
  maxAdults,
  minAdults,
  birthdates,
  childAges,
  childrenCount,
  className,
  labels,
  maxAuthorizedBirthdate,
  maxChildren,
  maxTotalParticipants = DEFAULT_MAX_TOTAL,
  minAuthorizedBirthdate,
  minChildren,
  onChange,
  onLimitDisplay,
  onReset,
  onApply,
  title,
  variant = 'card',
  withChildren = true,
  childrenFlow = 'age-or-birthdate-with-apply',
}) => {
  const isCard = variant === 'card';

  // Derived from props — no state needed
  const limitExceeded = adultsCount + childrenCount >= maxTotalParticipants;

  // Fire onLimitDisplay only on the first transition to exceeded
  const prevLimitExceeded = useRef(limitExceeded);
  useEffect(() => {
    if (limitExceeded && !prevLimitExceeded.current) {
      onLimitDisplay?.();
    }
    prevLimitExceeded.current = limitExceeded;
  }, [limitExceeded, onLimitDisplay]);

  const [useBirthdate, setUseBirthdate] = useState(false);
  const [ageUnit, setAgeUnit] = useState<'year' | 'month'>('year');
  const [draftAges, setDraftAges] = useState<Array<string>>(() => Array(childrenCount).fill(''));
  const [detailsExpanded, setDetailsExpanded] = useState(true);

  // Keep draftAges length in sync with childrenCount
  useEffect(() => {
    setDraftAges((prev) =>
      childrenCount > prev.length
        ? [...prev, ...Array(childrenCount - prev.length).fill('')]
        : prev.slice(0, childrenCount),
    );
  }, [childrenCount]);

  const showBirthdateToggle = childrenFlow !== 'birthdate-only';
  const showParticipantChips = childrenFlow === 'age-or-birthdate-with-apply';
  const showApplyButton = childrenFlow === 'age-or-birthdate-with-apply';

  const hasAgeEntrySection = withChildren && showBirthdateToggle && !!labels.enterDateOfBirth;
  const hasParticipantSummary =
    withChildren &&
    showParticipantChips &&
    !!labels.participantDetails &&
    (adultsCount > 0 || childrenCount > 0);

  const adultsField = (
    <ParticipantNumberField
      addLabel={labels.addAdult}
      description={labels.adultsDescription}
      label={labels.adults}
      max={maxAdults}
      min={minAdults}
      onChange={(value) => onChange({ type: 'adultsCount', payload: value })}
      removeLabel={labels.removeAdult}
      value={adultsCount}
    />
  );

  const birthdateFields = birthdates.length > 0 && (
    <div className={isCard ? 'space-y-12' : 'grid grid-cols-1 gap-20 md:grid-cols-2 md:gap-x-20'}>
      {birthdates.map((birthdate, index) => (
        <Birthdate
          key={index}
          label={labels.childBirthdate.replace('{n}', String(index + 1))}
          value={birthdate}
          min={minAuthorizedBirthdate ?? startOfToday()}
          max={maxAuthorizedBirthdate}
          invalidDateErrorMessage={labels.childBirthdateInvalidDateError}
          minDateErrorMessage={labels.childBirthdateMinAgeError}
          maxDateErrorMessage={labels.childBirthdateMaxAgeError}
          onChange={(_, value) => onChange({ type: 'birthdate', payload: { date: value, index } })}
        />
      ))}
    </div>
  );

  const yearFields = !useBirthdate && childrenCount > 0 && (
    <div className="space-y-12">
      {labels.ageQuestion && <p className="text-b4">{labels.ageQuestion}</p>}
      {Array.from({ length: childrenCount }, (_, index) => (
        <div key={index} className="space-y-4">
          <p className="text-b3 font-semibold px-20">
            {labels.childLabel ? interpolate(labels.childLabel, index + 1) : `Child ${index + 1}`}
          </p>
          <div className="flex items-center gap-12">
            <input
              aria-label={
                labels.childLabel
                  ? interpolate(labels.childLabel, index + 1)
                  : `Child ${index + 1} age`
              }
              className="rounded-pill border-lightGrey h-48 w-80 border px-20 text-b3 font-semibold"
              min={0}
              onChange={(e: ChangeEvent<HTMLInputElement>) => {
                const next = [...draftAges];
                next[index] = e.target.value;
                setDraftAges(next);
              }}
              type="number"
              value={draftAges[index] ?? ''}
            />
            {labels.ageYear && labels.ageMonths && (
              <Toggle
                size="small"
                index={ageUnit === 'year' ? 0 : 1}
                items={[
                  { id: 'year', color: 'saffron', label: labels.ageYear },
                  { id: 'month', color: 'saffron', label: labels.ageMonths },
                ]}
                onClick={(i) => setAgeUnit(i === 0 ? 'year' : 'month')}
              />
            )}
            {labels.ageApply && (
              <Button
                color="black"
                size="tertiary"
                onClick={() => {
                  const parsed = Number(draftAges[index]);
                  onChange({
                    type: 'childAge',
                    payload: {
                      index,
                      value: Number.isFinite(parsed) && parsed >= 0 ? parsed : null,
                      unit: ageUnit,
                    },
                  });
                }}
              >
                {labels.ageApply}
              </Button>
            )}
          </div>
        </div>
      ))}
    </div>
  );

  const participantSummary = hasParticipantSummary && (
    <div className="space-y-8">
      <button
        aria-expanded={detailsExpanded}
        className="flex w-full cursor-pointer items-center justify-between border-b border-lightGrey pb-4"
        onClick={() => setDetailsExpanded((v) => !v)}
        type="button"
      >
        <span className="text-b4">{labels.participantDetails}</span>
        <svg
          aria-hidden
          className={clsx('size-20 transition-transform', { 'rotate-180': detailsExpanded })}
          fill="none"
          stroke="currentColor"
          strokeWidth={2}
          viewBox="0 0 24 24"
        >
          <path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </button>
      <ElasticHeight isExpanded={detailsExpanded}>
        <div className="grid grid-cols-2 gap-8">
          {Array.from({ length: adultsCount }, (_, i) => (
            <ParticipantChip
              key={`adult-${i}`}
              ariaLabel={labels.removeParticipant}
              label={labels.adultLabel ? interpolate(labels.adultLabel, i + 1) : `Adult ${i + 1}`}
              onClick={() => onChange({ type: 'adultsCount', payload: adultsCount - 1 })}
              type="adult"
            />
          ))}
          {Array.from({ length: childrenCount }, (_, i) => {
            const ageLabel = computeAgeLabel(birthdates[i], childAges?.[i], labels);
            const name = labels.childLabel
              ? interpolate(labels.childLabel, i + 1)
              : `Child ${i + 1}`;
            return (
              <ParticipantChip
                key={`child-${i}`}
                ageLabel={ageLabel || undefined}
                ariaLabel={labels.removeParticipant}
                label={name}
                onClick={() => onChange({ type: 'childrenCount', payload: childrenCount - 1 })}
                type="child"
              />
            );
          })}
        </div>
      </ElasticHeight>
    </div>
  );

  const childrenSection = withChildren && (
    <div className="space-y-8">
      <ParticipantNumberField
        addLabel={labels.addChild}
        description={labels.childrenAgeLimit}
        label={labels.children}
        max={maxChildren}
        min={minChildren}
        onChange={(value) => onChange({ type: 'childrenCount', payload: value })}
        removeLabel={labels.removeChild}
        value={childrenCount}
      />
      <ElasticHeight
        isExpanded={
          (hasAgeEntrySection && childrenCount > 0) ||
          (!hasAgeEntrySection && birthdates.length > 0)
        }
      >
        <div className="border-lightGrey space-y-12 border-t pt-12">
          {hasAgeEntrySection && (
            <div className="flex items-center justify-between">
              <p className="text-b3 font-semibold">{labels.enterDateOfBirth}</p>
              <Switch checked={useBirthdate} onChange={(_, val) => setUseBirthdate(val)} />
            </div>
          )}
          {useBirthdate || !hasAgeEntrySection ? birthdateFields : yearFields}
        </div>
      </ElasticHeight>
      {participantSummary}
    </div>
  );

  if (!isCard) {
    return (
      <div className={clsx('space-y-20', className)}>
        {title && <p className="text-b2 font-semibold">{title}</p>}
        {limitExceeded && labels.limit && <LimitInformation {...labels.limit} />}
        {adultsField}
        {childrenSection}
        <Footer
          applyLabel={labels.apply}
          onApply={showApplyButton ? onApply : undefined}
          onReset={onReset}
          resetLabel={labels.reset}
        />
      </div>
    );
  }

  return (
    <div
      className={clsx(
        'rounded-16 border-lightGrey flex flex-col gap-20 border bg-white p-20',
        className,
      )}
    >
      <div className="rounded-16 bg-lightSand space-y-8 p-20">
        {title && <p className="text-b2 font-semibold">{title}</p>}
        {limitExceeded && labels.limit && <LimitInformation {...labels.limit} />}
        <div className="rounded-16 bg-white p-12">{adultsField}</div>
        {childrenSection && <div className="rounded-16 bg-white p-12">{childrenSection}</div>}
      </div>
      <Footer
        applyLabel={labels.apply}
        onApply={showApplyButton ? onApply : undefined}
        onReset={onReset}
        resetLabel={labels.reset}
        withSpacer
      />
    </div>
  );
};
```

## Dependencies

Install the `date-fns` peer dependency used for birthdate bound calculations:

```bash
npm install date-fns
```

## Usage

### Basic controlled usage

`CriteriaParticipants` is fully controlled. The parent manages `adultsCount`, `childrenCount`, the `birthdates` array, and optionally `childAges`, and updates them through a single `onChange` dispatcher that receives a typed action object.

```tsx
'use client';
import { useState } from 'react';
import { subYears } from 'date-fns';

import { CriteriaParticipants } from '@/ui/criteria/CriteriaParticipants';
import type { CriteriaParticipantsAction } from '@/ui/criteria/CriteriaParticipants';

const labels = {
  addAdult: 'Add adult',
  addChild: 'Add child',
  adults: 'Adults',
  children: 'Children',
  childrenAgeLimit: 'Under 18 y.o.',
  childBirthdate: 'Child {n} birthdate',
  childBirthdateInvalidDateError: 'Invalid date',
  childBirthdateMaxAgeError: 'Too young (under 2)',
  childBirthdateMinAgeError: 'Too old (over 17)',
  removeAdult: 'Remove adult',
  removeChild: 'Remove child',
  enterDateOfBirth: 'Enter date of birth',
  ageQuestion: 'How old will the children be at check-in?',
  ageYear: 'Year',
  ageMonths: 'Months',
  ageApply: 'Apply',
  participantDetails: 'Participant Details',
  adultLabel: 'Adult {n}',
  childLabel: 'Child {n}',
  removeParticipant: 'Remove',
  reset: 'Reset',
  apply: 'Continue',
};

export function CriteriaParticipantsDemo() {
  const [adultsCount, setAdults] = useState(2);
  const [childrenCount, setChildren] = useState(0);
  const [birthdates, setBirthdates] = useState<Array<Date | null | undefined>>([]);
  const [childAges, setChildAges] = useState<
    Array<{ value: number; unit: 'year' | 'month' } | null>
  >([]);

  const handleChange = (action: CriteriaParticipantsAction) => {
    if (action.type === 'adultsCount') setAdults(action.payload);
    if (action.type === 'childrenCount') {
      setChildren(action.payload);
      setBirthdates((prev) =>
        action.payload > prev.length ? [...prev, null] : prev.slice(0, action.payload),
      );
      setChildAges((prev) =>
        action.payload > prev.length
          ? [...prev, ...Array(action.payload - prev.length).fill(null)]
          : prev.slice(0, action.payload),
      );
    }
    if (action.type === 'birthdate') {
      setBirthdates((prev) => {
        const next = [...prev];
        next[action.payload.index] = action.payload.date;
        return next;
      });
    }
    if (action.type === 'childAge') {
      setChildAges((prev) => {
        const next = [...prev];
        next[action.payload.index] =
          action.payload.value !== null
            ? { value: action.payload.value, unit: action.payload.unit }
            : null;
        return next;
      });
    }
  };

  return (
    <div className="w-[380px] space-y-24">
      <CriteriaParticipants
        labels={labels}
        adultsCount={adultsCount}
        childrenCount={childrenCount}
        birthdates={birthdates}
        childAges={childAges}
        maxAuthorizedBirthdate={subYears(new Date(), 2)}
        minAuthorizedBirthdate={subYears(new Date(), 17)}
        onChange={handleChange}
        title="Participant"
        onReset={() => {
          setAdults(2);
          setChildren(0);
          setBirthdates([]);
          setChildAges([]);
        }}
        onApply={() => {}}
      />
      <p className="text-b4">
        {adultsCount} adult{adultsCount !== 1 ? 's' : ''}, {childrenCount} child
        {childrenCount !== 1 ? 'ren' : ''}
      </p>
    </div>
  );
}
```

## Children flows

`childrenFlow` controls how children enter their age information, whether an Apply button appears, and whether participant chips are shown. There are three modes.

### Flow 1 — Birthdate only

The simplest mode. Only birthdate fields are shown — no toggle, no year/month inputs. `onChange` fires immediately on each birthdate change. No Apply button, no participant chips.

```tsx
'use client';
import { useState } from 'react';
import { format, subYears } from 'date-fns';

import { CriteriaParticipants } from '@/ui/criteria/CriteriaParticipants';
import type { CriteriaParticipantsAction } from '@/ui/criteria/CriteriaParticipants';

const labels = {
  addAdult: 'Add adult',
  addChild: 'Add child',
  adults: 'Adults',
  children: 'Children',
  childrenAgeLimit: 'Under 18 y.o.',
  childBirthdate: 'Child {n} birthdate',
  childBirthdateInvalidDateError: 'Invalid date',
  childBirthdateMaxAgeError: 'Too young (under 2)',
  childBirthdateMinAgeError: 'Too old (over 17)',
  removeAdult: 'Remove adult',
  removeChild: 'Remove child',
};

export function CriteriaParticipantsFlow1Demo() {
  const [adultsCount, setAdults] = useState(2);
  const [childrenCount, setChildren] = useState(0);
  const [birthdates, setBirthdates] = useState<Array<Date | null | undefined>>([]);

  const handleChange = (action: CriteriaParticipantsAction) => {
    if (action.type === 'adultsCount') setAdults(action.payload);
    if (action.type === 'childrenCount') {
      setChildren(action.payload);
      setBirthdates((prev) =>
        action.payload > prev.length ? [...prev, null] : prev.slice(0, action.payload),
      );
    }
    if (action.type === 'birthdate') {
      setBirthdates((prev) => {
        const next = [...prev];
        next[action.payload.index] = action.payload.date;
        return next;
      });
    }
  };

  return (
    <div className="w-[380px] space-y-24">
      <CriteriaParticipants
        childrenFlow="birthdate-only"
        labels={labels}
        adultsCount={adultsCount}
        childrenCount={childrenCount}
        birthdates={birthdates}
        maxAuthorizedBirthdate={subYears(new Date(), 2)}
        minAuthorizedBirthdate={subYears(new Date(), 17)}
        onChange={handleChange}
        title="Participants"
      />
      <p className="text-b4">
        Birthdates:{' '}
        {birthdates.map((d) => (d ? format(d, 'yyyy-MM-dd') : '—')).join(', ') || 'none'}
      </p>
    </div>
  );
}
```

### Flow 2 — Age or birthdate (immediate)

The user toggles between a birthdate field and year/month age inputs per child. `onChange` fires immediately — the per-child Apply button commits each age entry as soon as it is clicked. No footer Apply button, no participant chips.

```tsx
'use client';
import { useState } from 'react';
import { format, subYears } from 'date-fns';

import { CriteriaParticipants } from '@/ui/criteria/CriteriaParticipants';
import type { CriteriaParticipantsAction } from '@/ui/criteria/CriteriaParticipants';

const labels = {
  addAdult: 'Add adult',
  addChild: 'Add child',
  adults: 'Adults',
  children: 'Children',
  childrenAgeLimit: 'Under 18 y.o.',
  childBirthdate: 'Child {n} birthdate',
  childBirthdateInvalidDateError: 'Invalid date',
  childBirthdateMaxAgeError: 'Too young (under 2)',
  childBirthdateMinAgeError: 'Too old (over 17)',
  removeAdult: 'Remove adult',
  removeChild: 'Remove child',
  enterDateOfBirth: 'Enter date of birth',
  ageQuestion: 'How old will the children be at check-in?',
  ageYear: 'Year',
  ageMonths: 'Months',
  ageApply: 'Apply',
};

export function CriteriaParticipantsFlow2Demo() {
  const [adultsCount, setAdults] = useState(2);
  const [childrenCount, setChildren] = useState(0);
  const [birthdates, setBirthdates] = useState<Array<Date | null | undefined>>([]);
  const [childAges, setChildAges] = useState<
    Array<{ value: number; unit: 'year' | 'month' } | null>
  >([]);

  const handleChange = (action: CriteriaParticipantsAction) => {
    if (action.type === 'adultsCount') setAdults(action.payload);
    if (action.type === 'childrenCount') {
      setChildren(action.payload);
      setBirthdates((prev) =>
        action.payload > prev.length ? [...prev, null] : prev.slice(0, action.payload),
      );
      setChildAges((prev) =>
        action.payload > prev.length
          ? [...prev, ...Array(action.payload - prev.length).fill(null)]
          : prev.slice(0, action.payload),
      );
    }
    if (action.type === 'birthdate') {
      setBirthdates((prev) => {
        const next = [...prev];
        next[action.payload.index] = action.payload.date;
        return next;
      });
    }
    if (action.type === 'childAge') {
      setChildAges((prev) => {
        const next = [...prev];
        next[action.payload.index] =
          action.payload.value !== null
            ? { value: action.payload.value, unit: action.payload.unit }
            : null;
        return next;
      });
    }
  };

  const summary = [
    ...birthdates.map((d, i) => (d ? `Child ${i + 1}: ${format(d, 'yyyy-MM-dd')}` : null)),
    ...childAges.map((a, i) =>
      a ? `Child ${i + 1}: ${a.value} ${a.unit === 'year' ? 'yr' : 'mo'}` : null,
    ),
  ]
    .filter(Boolean)
    .join(', ');

  return (
    <div className="w-[380px] space-y-24">
      <CriteriaParticipants
        childrenFlow="age-or-birthdate"
        labels={labels}
        adultsCount={adultsCount}
        childrenCount={childrenCount}
        birthdates={birthdates}
        childAges={childAges}
        maxAuthorizedBirthdate={subYears(new Date(), 2)}
        minAuthorizedBirthdate={subYears(new Date(), 17)}
        onChange={handleChange}
        title="Participants"
      />
      <p className="text-b4">{summary || 'No ages or birthdates entered yet'}</p>
    </div>
  );
}
```

### Flow 3 — Age or birthdate with Apply (default)

The same toggle as flow 2. A footer Apply button lets the user confirm their full selection. Participant chips appear showing each confirmed adult and child with their age. Clicking a chip removes that participant. Pass `onReset` and `onApply` to wire up the footer actions.

```tsx
'use client';
import { useState } from 'react';
import { subYears } from 'date-fns';

import { CriteriaParticipants } from '@/ui/criteria/CriteriaParticipants';
import type { CriteriaParticipantsAction } from '@/ui/criteria/CriteriaParticipants';

const labels = {
  addAdult: 'Add adult',
  addChild: 'Add child',
  adults: 'Adults',
  children: 'Children',
  childrenAgeLimit: 'Under 18 y.o.',
  childBirthdate: 'Child {n} birthdate',
  childBirthdateInvalidDateError: 'Invalid date',
  childBirthdateMaxAgeError: 'Too young (under 2)',
  childBirthdateMinAgeError: 'Too old (over 17)',
  removeAdult: 'Remove adult',
  removeChild: 'Remove child',
  enterDateOfBirth: 'Enter date of birth',
  ageQuestion: 'How old will the children be at check-in?',
  ageYear: 'Year',
  ageMonths: 'Months',
  ageApply: 'Apply',
  participantDetails: 'Participant Details',
  adultLabel: 'Adult {n}',
  childLabel: 'Child {n}',
  removeParticipant: 'Remove',
  reset: 'Reset',
  apply: 'Continue',
};

export function CriteriaParticipantsFlow3Demo() {
  const [adultsCount, setAdults] = useState(2);
  const [childrenCount, setChildren] = useState(0);
  const [birthdates, setBirthdates] = useState<Array<Date | null | undefined>>([]);
  const [childAges, setChildAges] = useState<
    Array<{ value: number; unit: 'year' | 'month' } | null>
  >([]);
  const [confirmed, setConfirmed] = useState('');

  const handleChange = (action: CriteriaParticipantsAction) => {
    if (action.type === 'adultsCount') setAdults(action.payload);
    if (action.type === 'childrenCount') {
      setChildren(action.payload);
      setBirthdates((prev) =>
        action.payload > prev.length ? [...prev, null] : prev.slice(0, action.payload),
      );
      setChildAges((prev) =>
        action.payload > prev.length
          ? [...prev, ...Array(action.payload - prev.length).fill(null)]
          : prev.slice(0, action.payload),
      );
    }
    if (action.type === 'birthdate') {
      setBirthdates((prev) => {
        const next = [...prev];
        next[action.payload.index] = action.payload.date;
        return next;
      });
    }
    if (action.type === 'childAge') {
      setChildAges((prev) => {
        const next = [...prev];
        next[action.payload.index] =
          action.payload.value !== null
            ? { value: action.payload.value, unit: action.payload.unit }
            : null;
        return next;
      });
    }
  };

  return (
    <div className="w-[380px] space-y-24">
      <CriteriaParticipants
        childrenFlow="age-or-birthdate-with-apply"
        labels={labels}
        adultsCount={adultsCount}
        childrenCount={childrenCount}
        birthdates={birthdates}
        childAges={childAges}
        maxAuthorizedBirthdate={subYears(new Date(), 2)}
        minAuthorizedBirthdate={subYears(new Date(), 17)}
        onChange={handleChange}
        title="Participant"
        onReset={() => {
          setAdults(2);
          setChildren(0);
          setBirthdates([]);
          setChildAges([]);
          setConfirmed('');
        }}
        onApply={() => {
          setConfirmed(
            `${adultsCount} adult${adultsCount !== 1 ? 's' : ''}, ${childrenCount} child${childrenCount !== 1 ? 'ren' : ''}`,
          );
        }}
      />
      <p className="text-b4">{confirmed ? `Confirmed: ${confirmed}` : 'Not yet confirmed'}</p>
    </div>
  );
}
```

### Flow comparison

| `childrenFlow` | Age toggle | Footer Apply | Participant chips | `onChange` fires on |
| --- | --- | --- | --- | --- |
| `birthdate-only` | No | No | No | Each birthdate change |
| `age-or-birthdate` | Yes | No | No | Each birthdate change or per-child Apply click |
| `age-or-birthdate-with-apply` (default) | Yes | Yes | Yes | Each birthdate change or per-child Apply click |

## Variants

### Card (default)

Sand background (`bg-lightSand`, `rounded-16`, `p-20`) wrapping white rounded containers for each field group. Use this in floating search panels, resort selectors, and booking drawers.

### Flat

No background or card chrome. Fields are rendered directly with `space-y-20` spacing. Birthdate inputs are laid out in a single column on mobile and a two-column grid on medium screens. Use inside a form layout that provides its own container.

```tsx showLineNumbers
<CriteriaParticipants
  variant="flat"
  labels={labels}
  adultsCount={adultsCount}
  childrenCount={childrenCount}
  birthdates={birthdates}
  maxAuthorizedBirthdate={subYears(new Date(), 2)}
  onChange={handleChange}
/>
```

## More usage

### Without children

Set `withChildren={false}` to hide the children section entirely. This is useful for booking flows where children are not permitted, or when they are handled in a separate step.

```tsx showLineNumbers
<CriteriaParticipants
  withChildren={false}
  labels={labels}
  adultsCount={adultsCount}
  childrenCount={0}
  birthdates={[]}
  maxAuthorizedBirthdate={subYears(new Date(), 2)}
  onChange={handleChange}
/>
```

### With participant limit banner

When the combined total of adults and children reaches `maxTotalParticipants` (default `6`), a limit banner appears. Pass the full `limit` object inside `labels` to enable it. The optional `onLimitDisplay` callback fires exactly once the first time the total is reached.

```tsx showLineNumbers
const labels = {
  // ...other labels
  limit: {
    title: 'Maximum participants reached',
    description: 'Our resorts accommodate a maximum of 6 guests per booking.',
    contacts: [
      {
        title: 'Call us',
        description: 'Mon–Fri, 9 am – 6 pm',
        href: 'tel:+33123456789',
        icon: 'Phone',
        target: '_self',
      },
    ],
    labels: { expand: 'Read more', reduce: 'Read less' },
  },
};

<CriteriaParticipants
  labels={labels}
  maxTotalParticipants={6}
  onLimitDisplay={() => analytics.track('participant_limit_reached')}
  adultsCount={adultsCount}
  childrenCount={childrenCount}
  birthdates={birthdates}
  maxAuthorizedBirthdate={subYears(new Date(), 2)}
  onChange={handleChange}
/>
```

### Adults-only description

Pass `adultsDescription` inside `labels` to show a sub-label below the adults field label. The hidden `input` aria-label automatically combines the label and description for screen readers.

```tsx showLineNumbers
<CriteriaParticipants
  labels={{ ...labels, adultsDescription: '18 years and over' }}
  adultsCount={adultsCount}
  childrenCount={childrenCount}
  birthdates={birthdates}
  maxAuthorizedBirthdate={subYears(new Date(), 2)}
  onChange={handleChange}
/>
```

## API Reference

### CriteriaParticipants

| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `labels` | `CriteriaParticipantsLabels` | — | **Required.** All user-facing strings. See [CriteriaParticipantsLabels](#criteriaparticipantslabels). |
| `adultsCount` | `number` | — | **Required.** Controlled adults count. |
| `childrenCount` | `number` | — | **Required.** Controlled children count. |
| `birthdates` | `Array<Date \| null \| undefined>` | — | **Required.** One entry per child. Length must match `childrenCount`. |
| `maxAuthorizedBirthdate` | `Date` | — | **Required.** Latest valid birthdate (child is too young if born after this). Typically `subYears(new Date(), minAge)`. |
| `onChange` | `Dispatch<CriteriaParticipantsAction>` | — | **Required.** Dispatcher for all state changes. See [CriteriaParticipantsAction](#criteriaparticipantsaction). |
| `childrenFlow` | `'birthdate-only' \| 'age-or-birthdate' \| 'age-or-birthdate-with-apply'` | `'age-or-birthdate-with-apply'` | Controls the children age-entry UX, Apply button visibility, and chip display. |
| `childAges` | `Array<{ value: number; unit: 'year' \| 'month' } \| null \| undefined>` | `undefined` | Applied child ages for year/month mode. One entry per child. |
| `minAdults` | `number` | `undefined` | Minimum adults count. Decrease button is disabled at this value. |
| `maxAdults` | `number` | `undefined` | Maximum adults count. Increase button is disabled at this value. |
| `minChildren` | `number` | `undefined` | Minimum children count. |
| `maxChildren` | `number` | `undefined` | Maximum children count. |
| `withChildren` | `boolean` | `true` | When `false`, hides the children section entirely. |
| `minAuthorizedBirthdate` | `Date` | today | Earliest valid birthdate (child is too old if born before this). Typically `subYears(new Date(), maxAge)`. |
| `maxTotalParticipants` | `number` | `6` | Combined adults + children at which the limit banner appears. |
| `variant` | `'card' \| 'flat'` | `'card'` | Visual layout variant. |
| `title` | `string` | `undefined` | Heading at the top of the component. |
| `className` | `string` | `undefined` | Additional CSS class on the root element. |
| `onLimitDisplay` | `() => void` | `undefined` | Fired once when the total first reaches `maxTotalParticipants`. |
| `onReset` | `() => void` | `undefined` | Footer Reset button handler. Button only renders when this is provided and `labels.reset` is set. |
| `onApply` | `() => void` | `undefined` | Footer Apply button handler. Button only renders in `age-or-birthdate-with-apply` flow when this and `labels.apply` are set. |

### CriteriaParticipantsLabels

| Field | Type | Description |
| ----- | ---- | ----------- |
| `adults` | `string` | Adults field label. |
| `adultsDescription` | `string \| null \| undefined` | Optional sub-label under the adults field (e.g. _"18 years and over"_). |
| `addAdult` | `string` | `aria-label` for the adults increment button. |
| `removeAdult` | `string` | `aria-label` for the adults decrement button. |
| `children` | `string` | Children field label. |
| `childrenAgeLimit` | `string` | Age-range hint shown under the children field label (e.g. _"2–17 years"_). |
| `addChild` | `string` | `aria-label` for the children increment button. |
| `removeChild` | `string` | `aria-label` for the children decrement button. |
| `childBirthdate` | `string` | Birthdate field label. Use `{n}` for the 1-based child index. |
| `childBirthdateInvalidDateError` | `string` | Error when the entered value is not a valid date. |
| `childBirthdateMinAgeError` | `string` | Error when the child exceeds the maximum allowed age. |
| `childBirthdateMaxAgeError` | `string` | Error when the child is below the minimum allowed age. |
| `enterDateOfBirth` | `string \| undefined` | Toggle label for switching between birthdate and age inputs. Omit to suppress the toggle (birthdate-only always shows birthdate fields; other flows always show age inputs). |
| `ageQuestion` | `string \| undefined` | Question text shown above the per-child age inputs. |
| `ageYear` | `string \| undefined` | "Year" tab label in the year/month Toggle. |
| `ageMonths` | `string \| undefined` | "Months" tab label in the year/month Toggle. |
| `ageApply` | `string \| undefined` | Per-child Apply button label. |
| `participantDetails` | `string \| undefined` | Collapsible chip section title. Omit to hide the chip section entirely. |
| `adultLabel` | `string \| undefined` | Chip label template for adults. Use `{n}` for the 1-based index (e.g. `"Adult {n}"`). |
| `childLabel` | `string \| undefined` | Chip label template for children. Use `{n}` for the 1-based index (e.g. `"Child {n}"`). |
| `removeParticipant` | `string \| undefined` | `aria-label` for chip remove buttons. |
| `reset` | `string \| undefined` | Footer Reset link label. |
| `apply` | `string \| undefined` | Footer Apply button label. |
| `limit` | `ParticipantsLimit \| undefined` | Content for the limit banner. Omit to suppress the banner entirely. |

### CriteriaParticipantsAction

The `onChange` dispatcher receives one of four action shapes:

```ts showLineNumbers
type CriteriaParticipantsAction =
  | { type: 'adultsCount'; payload: number }
  | { type: 'childrenCount'; payload: number }
  | { type: 'birthdate'; payload: { date: Date | null; index: number } }
  | { type: 'childAge'; payload: { index: number; value: number | null; unit: 'year' | 'month' } };
```

- **`adultsCount`** — dispatched by the adults `NumberField`. `payload` is the new count.
- **`childrenCount`** — dispatched by the children `NumberField`. `payload` is the new count. Resize the `birthdates` and `childAges` arrays to match in the same handler.
- **`birthdate`** — dispatched when a child's birthdate field changes. `payload.index` is the 0-based child position; `payload.date` is the new `Date` or `null` when cleared.
- **`childAge`** — dispatched when a per-child Apply button is clicked in age mode. `payload.index` is the 0-based child position; `payload.value` is the age (`null` if input was empty); `payload.unit` is `'year'` or `'month'`.

### ParticipantsLimit

Provided via `labels.limit`. Omit the key to disable the limit banner entirely.

| Field | Type | Description |
| ----- | ---- | ----------- |
| `title` | `string` | Banner heading. Displayed next to an error icon. |
| `description` | `string` | Body text shown in the expandable section. |
| `contacts` | `ParticipantsLimitContact[]` | Zero or more contact links rendered below the description. |
| `labels.expand` | `string` | "Read more" toggle label. |
| `labels.reduce` | `string` | "Read less" toggle label. |

### ParticipantsLimitContact

| Field | Type | Description |
| ----- | ---- | ----------- |
| `title` | `string` | Link heading. |
| `description` | `string \| null` | Optional sub-description below the title. |
| `href` | `string` | Link `href` (e.g. `tel:+33...`). |
| `icon` | `string` | Icon name rendered in the link chip. |
| `target` | `string` | Link `target` attribute (e.g. `'_self'`, `'_blank'`). |

## Behaviour

### Keeping arrays in sync

The `birthdates` and `childAges` array lengths must match `childrenCount` at all times. The recommended pattern is to grow or shrink both arrays inside the `childrenCount` branch of `onChange`: append `null` for each new child, and truncate when children are removed.

### Limit banner

The limit banner appears once the first time `adultsCount + childrenCount >= maxTotalParticipants`. It remains visible for the rest of the session. The `onLimitDisplay` callback fires exactly once per mount at the moment the threshold is first crossed.

### Birthdate validation

Each birthdate field validates four conditions in priority order:

1. **Invalid date** — the entered value is not a parseable calendar date.
2. **Future date** — the entered birthdate is after today.
3. **Too old** — birthdate is before `minAuthorizedBirthdate`.
4. **Too young** — birthdate is after `maxAuthorizedBirthdate`.

`minDateErrorMessage` is shown for condition 3 and `maxDateErrorMessage` for condition 4 — named from the perspective of the date bound, not the age direction.

### Participant chips

The chip section is collapsible and only appears in `age-or-birthdate-with-apply` flow when `labels.participantDetails` is provided. One chip is shown per confirmed adult and child. Child chips display the applied age or computed birthdate age as a label. Clicking any chip decrements the corresponding count by one.

### childrenFlow and the Apply button

The footer Apply button only renders in `age-or-birthdate-with-apply` flow. In flows 1 and 2, `onApply` is accepted but the button is suppressed. The `onReset` footer link is shown in all flows when provided.

## Accessibility

- Adults and children `NumberField` components expose labelled increment and decrement buttons. The hidden `<input>` carries an `aria-label` combining the field label with any description.
- Birthdate fields render with a visible label and an inline error message associated via `aria-describedby`.
- The year/month `Toggle` uses `aria-pressed` on each button and `role="group"` on the container.
- The limit banner heading is assigned a unique `id` used by the expandable section for `aria-controls` association.
- Participant chips are `<button>` elements with an `aria-label` from `labels.removeParticipant`.
- The chip section header is a `<button>` with `aria-expanded` reflecting its open/closed state.
- The `CrossDissolve` transition between birthdate and age fields marks the outgoing slot as `aria-hidden` during the animation to prevent duplicate announcements.
