shadimage
buzzform
uicheckboxsource: ok · hit

CheckboxField

Checkbox, checkbox-group, and tristate checkbox field for BuzzForm v2.

npx shadcn add "https://form.buildnbuzz.com/r/checkbox.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@base-ui/react

Registry dependencies

@buzzform/initcheckboxfield

Files4 · source cached 10/9/2026, 12:21:46 AM

registry/shadcn/fields/checkbox.tsx
"use client";

import type { CheckboxField as CheckboxFieldDef } from "@buildnbuzz/form-react";
import { useDataField } from "@buildnbuzz/form-react";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";

interface CheckboxUi {
  autoFocus?: boolean;
  className?: string;
  width?: string | number;
  asterisk?: boolean;
}

export function CheckboxField() {
  const {
    fieldApi,
    field,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    ariaDescribedBy,
    handleChange,
    handleBlur,
  } = useDataField<CheckboxFieldDef>();

  // Delegate to group renderer if hasMany
  if ("hasMany" in field && field.hasMany) {
    return <CheckboxGroupField />;
  }

  // Delegate to tristate renderer if tristate
  if ("tristate" in field && field.tristate) {
    return <TristateCheckboxField />;
  }

  const value = (fieldApi.state.value as boolean) ?? false;
  const ui = field.ui as CheckboxUi | undefined;

  const width = ui?.width;
  const widthStyle = width
    ? {
        width: typeof width === "number" ? `${width}px` : width,
        flex: "0 1 auto",
      }
    : undefined;

  return (
    <FieldGroup
      data-field={fieldApi.name}
      className={ui?.className}
      style={widthStyle}
    >
      <Field
        orientation="horizontal"
        data-invalid={isInvalid}
        data-disabled={isDisabled}
      >
        <Checkbox
          id={fieldApi.name}
          checked={value}
          onCheckedChange={(checked) => {
            if (isReadOnly) return;
            handleChange(!!checked);
            handleBlur();
          }}
          disabled={isDisabled}
          aria-invalid={isInvalid}
          aria-describedby={ariaDescribedBy}
          aria-readonly={isReadOnly}
          autoFocus={ui?.autoFocus}
        />

        <FieldContent>
          {label && (
            <FieldLabel
              htmlFor={fieldApi.name}
              className="cursor-pointer m-0 font-normal inline"
            >
              {label}
              {isRequired && ui?.asterisk !== false && (
                <span className="text-destructive">&nbsp;*</span>
              )}
            </FieldLabel>
          )}

          {description && !isInvalid && (
            <FieldDescription id={descriptionId}>
              {description}
            </FieldDescription>
          )}

          {isInvalid && <FieldError id={errorId} errors={errors} />}
        </FieldContent>
      </Field>
    </FieldGroup>
  );
}

import { CheckboxGroupField } from "./checkbox-group";
import { TristateCheckboxField } from "./tristate-checkbox";
registry/shadcn/fields/checkbox-group.tsx
"use client";

import type { CheckboxGroupField as CheckboxGroupFieldDef } from "@buildnbuzz/form-react";
import { useDataField, useFieldOptions } from "@buildnbuzz/form-react";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { cn } from "@/lib/utils";

type OptionGroupVariant = "default" | "card";
type OptionGroupDirection = "vertical" | "horizontal";
type OptionGroupColumns = 1 | 2 | 3 | 4 | string | number | undefined;

interface OptionUi {
  description?: string;
}

interface CheckboxGroupUi {
  variant?: OptionGroupVariant;
  direction?: OptionGroupDirection;
  columns?: OptionGroupColumns;
  card?: {
    size?: "sm" | "md" | "lg";
    bordered?: boolean;
  };
  autoFocus?: boolean;
  className?: string;
  width?: string | number;
  asterisk?: boolean;
}

const cardSizeClasses = {
  sm: "p-2.5 sm:p-3",
  md: "p-3 sm:p-4",
  lg: "p-4 sm:p-5",
} as const;

function getGridColumnsClass(columns: OptionGroupColumns | undefined) {
  if (columns === 2) return "sm:grid-cols-2";
  if (columns === 3) return "sm:grid-cols-2 md:grid-cols-3";
  if (columns === 4) return "sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4";
  return undefined;
}

function getLayoutClass({
  variant = "default",
  direction = "vertical",
  columns,
}: {
  variant?: OptionGroupVariant;
  direction?: OptionGroupDirection;
  columns?: OptionGroupColumns;
}) {
  const isCard = variant === "card";
  const isHorizontal = direction === "horizontal";

  if (isHorizontal && !columns)
    return "!flex flex-row flex-wrap gap-x-4 gap-y-2";

  const effectiveColumns =
    isCard || isHorizontal ? (columns ?? (isCard ? 2 : undefined)) : undefined;

  if (!effectiveColumns || effectiveColumns === 1) return "flex flex-col gap-2";

  return cn("grid gap-2", getGridColumnsClass(effectiveColumns));
}

export function CheckboxGroupField() {
  const {
    fieldApi,
    field,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    ariaDescribedBy,
    handleBlur,
  } = useDataField<CheckboxGroupFieldDef>();

  const { options, isLoading } = useFieldOptions(field.options);
  const ui = field.ui as CheckboxGroupUi | undefined;

  const selected = Array.isArray(fieldApi.state.value)
    ? (fieldApi.state.value as string[])
    : [];

  const variant = ui?.variant ?? "default";
  const direction = ui?.direction ?? "vertical";
  const columns = ui?.columns;
  const cardSize = ui?.card?.size ?? "md";
  const cardBordered = ui?.card?.bordered ?? true;
  const isCardVariant = variant === "card";
  const isHorizontal = direction === "horizontal";
  const layoutClass = getLayoutClass({ variant, direction, columns });

  const width = ui?.width;
  const widthStyle = width
    ? {
        width: typeof width === "number" ? `${width}px` : width,
        flex: "0 1 auto",
      }
    : undefined;

  const handleToggle = (val: string, checked: boolean) => {
    if (isReadOnly) return;
    const maxSelected = field.maxSelected;
    const hasMax = typeof maxSelected === "number";
    if (checked && hasMax && selected.length >= maxSelected) return;

    const next = checked
      ? [...selected, val]
      : selected.filter((v) => v !== val);

    fieldApi.handleChange(next);
    handleBlur();
  };

  return (
    <FieldGroup
      data-field={fieldApi.name}
      className={ui?.className}
      style={widthStyle}
    >
      <div
        className="flex flex-col gap-2"
        data-invalid={isInvalid}
        data-disabled={isDisabled}
      >
        {label && (
          <FieldLabel className="gap-1 items-baseline">
            {isRequired && ui?.asterisk !== false && (
              <span className="text-destructive">*</span>
            )}
            {label}
          </FieldLabel>
        )}

        {description && !isInvalid && (
          <FieldDescription id={descriptionId} className="-mt-1 mb-1">
            {description}
          </FieldDescription>
        )}

        <div
          className={layoutClass}
          role="group"
          aria-describedby={ariaDescribedBy}
        >
          {isLoading ? (
            <div className="py-2 text-sm text-muted-foreground animate-pulse">
              Loading options...
            </div>
          ) : (
            options.map((opt, i) => {
            const {
              value: val,
              label: optLabel,
              disabled: optDisabled,
              ui: optUi,
            } = opt;
            const optDesc = (optUi as OptionUi | undefined)?.description;
            const checked = selected.includes(val);
            const hasMax = typeof field.maxSelected === "number";
            const atMax = hasMax && selected.length >= field.maxSelected!;
            const isOptDisabled =
              optDisabled || isDisabled || (atMax && !checked);
            const id = `${fieldApi.name}-${i}`;

            if (isCardVariant) {
              const showDescription = optDesc && cardSize !== "sm";
              return (
                <label
                  key={`${val}-${i}`}
                  htmlFor={id}
                  className={cn(
                    "cursor-pointer transition-all duration-150 rounded-lg flex items-start gap-3",
                    cardSizeClasses[cardSize],
                    cardBordered && "border",
                    cardBordered &&
                      checked &&
                      "border-primary ring-1 ring-primary/20 bg-primary/5 dark:bg-primary/10",
                    cardBordered &&
                      !checked &&
                      "border-border hover:border-foreground/20 hover:bg-accent/30",
                    !cardBordered && "hover:bg-accent/50",
                    !cardBordered && checked && "bg-accent",
                    isOptDisabled &&
                      "opacity-50 cursor-not-allowed pointer-events-none",
                  )}
                  data-checked={checked}
                  data-disabled={isOptDisabled}
                >
                  <Checkbox
                    id={id}
                    checked={checked}
                    onCheckedChange={(next) => handleToggle(val, next === true)}
                    disabled={isOptDisabled}
                    className="shrink-0 mt-0.5"
                    autoFocus={ui?.autoFocus && i === 0}
                  />
                  <div className="flex flex-col gap-0.5 min-w-0 flex-1">
                    <div className="flex items-center gap-2">
                      <span
                        className={cn(
                          "font-medium",
                          cardSize === "lg" ? "text-base" : "text-sm",
                        )}
                      >
                        {optLabel}
                      </span>
                    </div>
                    {showDescription && (
                      <span
                        className={cn(
                          "text-muted-foreground line-clamp-2",
                          cardSize === "lg" ? "text-sm" : "text-xs",
                        )}
                      >
                        {optDesc}
                      </span>
                    )}
                  </div>
                </label>
              );
            }

            return (
              <Field
                key={`${val}-${i}`}
                orientation="horizontal"
                className={cn(
                  "items-center gap-2.5 space-y-0",
                  isHorizontal && !columns && "w-auto",
                  isOptDisabled && "opacity-50 cursor-not-allowed",
                )}
              >
                <Checkbox
                  id={id}
                  checked={checked}
                  onCheckedChange={(next) => handleToggle(val, next === true)}
                  disabled={isOptDisabled}
                  autoFocus={ui?.autoFocus && i === 0}
                />
                <FieldLabel
                  htmlFor={id}
                  className={cn(
                    "font-normal cursor-pointer m-0 flex items-center gap-2 text-sm",
                    isOptDisabled && "cursor-not-allowed",
                  )}
                >
                  {optLabel}
                </FieldLabel>
              </Field>
            );
            })
          )}
          {options.length === 0 && !isLoading && (
            <div className="py-2 text-sm text-muted-foreground italic">
              No options available
            </div>
          )}
        </div>

        {isInvalid && <FieldError id={errorId} errors={errors} />}
      </div>
    </FieldGroup>
  );
}
registry/shadcn/fields/tristate-checkbox.tsx
"use client";

import type { TristateCheckboxField as TristateCheckboxFieldDef } from "@buildnbuzz/form-react";
import { useDataField } from "@buildnbuzz/form-react";
import { Checkbox } from "../components/checkbox";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";

interface TristateCheckboxUi {
  autoFocus?: boolean;
  className?: string;
  width?: string | number;
  asterisk?: boolean;
}

/** Cycles null → true → false → null on each click. */
function nextTristateValue(current: boolean | null): boolean | null {
  if (current === null) return true;
  if (current === true) return false;
  return null;
}

/**
 * A tristate checkbox field component.
 *
 * Uses a registry Checkbox wrapper to support indeterminate state
 * with icon placeholders.
 */
export function TristateCheckboxField() {
  const {
    fieldApi,
    field,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    ariaDescribedBy,
    handleChange,
    handleBlur,
  } = useDataField<TristateCheckboxFieldDef>();

  const value = (fieldApi.state.value as boolean | null) ?? null;
  const ui = field.ui as TristateCheckboxUi | undefined;

  const width = ui?.width;
  const widthStyle = width
    ? {
        width: typeof width === "number" ? `${width}px` : width,
        flex: "0 1 auto",
      }
    : undefined;

  // Base UI uses `checked` (boolean) + `indeterminate` (boolean) separately
  const checked = value === true;
  const indeterminate = value === null;

  return (
    <FieldGroup
      data-field={fieldApi.name}
      className={ui?.className}
      style={widthStyle}
    >
      <Field
        orientation="horizontal"
        data-invalid={isInvalid}
        data-disabled={isDisabled}
      >
        <Checkbox
          id={fieldApi.name}
          checked={checked}
          indeterminate={indeterminate}
          onCheckedChange={() => {
            if (isReadOnly) return;
            handleChange(nextTristateValue(value));
            handleBlur();
          }}
          disabled={isDisabled}
          aria-invalid={isInvalid}
          aria-describedby={ariaDescribedBy}
          aria-readonly={isReadOnly}
          autoFocus={ui?.autoFocus}
          data-slot="checkbox"
        />

        <FieldContent>
          {label && (
            <FieldLabel
              htmlFor={fieldApi.name}
              className="cursor-pointer m-0 font-normal inline"
            >
              {label}
              {isRequired && ui?.asterisk !== false && (
                <span className="text-destructive">&nbsp;*</span>
              )}
            </FieldLabel>
          )}

          {description && !isInvalid && (
            <FieldDescription id={descriptionId}>
              {description}
            </FieldDescription>
          )}

          {isInvalid && <FieldError id={errorId} errors={errors} />}
        </FieldContent>
      </Field>
    </FieldGroup>
  );
}
registry/shadcn/components/checkbox.tsx
"use client";

import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";

export type CheckboxProps = Omit<CheckboxPrimitive.Root.Props, "checked"> & {
  checked?: boolean | "indeterminate";
  indeterminate?: boolean;
};

export function Checkbox({
  className,
  checked,
  indeterminate,
  ...props
}: CheckboxProps) {
  const isIndeterminate = indeterminate ?? checked === "indeterminate";
  const normalizedChecked = checked === "indeterminate" ? false : checked;

  return (
    <CheckboxPrimitive.Root
      data-slot="checkbox"
      checked={normalizedChecked}
      indeterminate={isIndeterminate}
      data-indeterminate={isIndeterminate ? "" : undefined}
      className={cn(
        "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground dark:data-indeterminate:bg-primary data-indeterminate:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-[3px] aria-invalid:ring-[3px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
        className,
      )}
      {...props}
    >
      <CheckboxPrimitive.Indicator
        data-slot="checkbox-indicator"
        className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
      >
        {isIndeterminate ? (
          <IconPlaceholder
            hugeicons="MinusSignIcon"
            lucide="Minus"
            tabler="IconMinus"
            phosphor="Minus"
            remixicon="RiSubtractLine"
          />
        ) : (
          <IconPlaceholder
            hugeicons="Tick02Icon"
            lucide="Check"
            tabler="IconCheck"
            phosphor="Check"
            remixicon="RiCheckLine"
          />
        )}
      </CheckboxPrimitive.Indicator>
    </CheckboxPrimitive.Root>
  );
}

More from buzzform

Browse all components in this library →