Skip to content

Checkbox Group

Several answers out of a visible list, each with an optional description.

Several answers out of a visible list, each with an optional description. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

"use client"

import * as React from "react"

import {
  CheckboxGroup,
  CheckboxOption,
} from "@/registry/bases/newt/ui/checkbox-group"

export default function CheckboxGroupDemo() {
  const [days, setDays] = React.useState<readonly string[]>(["mon", "tue"])

  return (
    <CheckboxGroup
      className="w-full max-w-[360px]"
      label="Which days are you free?"
      value={days}
      onValueChange={setDays}
    >
      <CheckboxOption value="mon">Monday</CheckboxOption>
      <CheckboxOption value="tue">Tuesday</CheckboxOption>
      <CheckboxOption value="sat" description="Short notice, but possible">
        Saturday
      </CheckboxOption>
      <CheckboxOption value="sun" disabled>
        Sunday
      </CheckboxOption>
    </CheckboxGroup>
  )
}
<script setup lang="ts">
import { ref } from "vue"
import {
  CheckboxGroup,
  CheckboxOption,
} from "~~/registry/bases/newt/ui/checkbox-group"

const days = ref<readonly string[]>(["mon", "tue"])
</script>

<template>
  <CheckboxGroup
    v-model="days"
    class="w-full max-w-[360px]"
    label="Which days are you free?"
  >
    <CheckboxOption value="mon">Monday</CheckboxOption>
    <CheckboxOption value="tue">Tuesday</CheckboxOption>
    <CheckboxOption value="sat" description="Short notice, but possible">
      Saturday
    </CheckboxOption>
    <CheckboxOption value="sun" disabled>Sunday</CheckboxOption>
  </CheckboxGroup>
</template>

Installation

pnpm dlx newtui@latest add checkbox-group
npx newtui@latest add checkbox-group
yarn dlx newtui@latest add checkbox-group
bunx newtui@latest add checkbox-group

Copy the source into your project

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

/*
 * Several answers out of a visible list, where a radio group takes one. The
 * group owns the set of chosen values; each row is a real `<input>`, so the
 * space bar, the label association and the indeterminate state all come for
 * free and only the box is redrawn.
 */
interface CheckboxGroupContextValue {
  readonly values: readonly string[]
  readonly toggle: (value: string) => void
  readonly name: string
}

const CheckboxGroupContext =
  React.createContext<CheckboxGroupContextValue | null>(null)

function useCheckboxGroup(component: string): CheckboxGroupContextValue {
  const context = React.useContext(CheckboxGroupContext)
  if (context === null) {
    throw new Error(`<${component}> must be used inside a <CheckboxGroup>.`)
  }
  return context
}

export interface CheckboxGroupProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "onChange" | "defaultValue"
> {
  /** Controlled selection. Leave undefined to let the group own it. */
  value?: readonly string[]
  defaultValue?: readonly string[]
  onValueChange?: (value: readonly string[]) => void
  /** Accessible name for the group. */
  label?: string
  /** Shared `name` for the inputs. One is generated when it is not given. */
  name?: string
}

const CheckboxGroup = React.forwardRef<HTMLDivElement, CheckboxGroupProps>(
  (
    {
      className,
      value,
      defaultValue = [],
      onValueChange,
      label,
      name,
      children,
      ...props
    },
    ref
  ) => {
    const generated = React.useId()
    const [internal, setInternal] =
      React.useState<readonly string[]>(defaultValue)
    const isControlled = value !== undefined
    const current = isControlled ? value : internal

    const toggle = React.useCallback(
      (option: string) => {
        const next = current.includes(option)
          ? current.filter((each) => each !== option)
          : [...current, option]
        if (!isControlled) setInternal(next)
        onValueChange?.(next)
      },
      [current, isControlled, onValueChange]
    )

    const context = React.useMemo<CheckboxGroupContextValue>(
      () => ({ values: current, toggle, name: name ?? generated }),
      [current, toggle, name, generated]
    )

    return (
      <CheckboxGroupContext.Provider value={context}>
        <div
          ref={ref}
          role="group"
          aria-label={label}
          className={cn("flex flex-col gap-2", className)}
          {...props}
        >
          {children}
        </div>
      </CheckboxGroupContext.Provider>
    )
  }
)
CheckboxGroup.displayName = "CheckboxGroup"

export interface CheckboxOptionProps extends Omit<
  React.InputHTMLAttributes<HTMLInputElement>,
  "type" | "checked" | "defaultChecked" | "value"
> {
  /** What this row stands for in the group's value. */
  value: string
  /** The quieter line under the label. */
  description?: React.ReactNode
}

const CheckboxOption = React.forwardRef<HTMLInputElement, CheckboxOptionProps>(
  ({ className, value, description, disabled, children, ...props }, ref) => {
    const group = useCheckboxGroup("CheckboxOption")
    const checked = group.values.includes(value)

    return (
      <label
        className={cn(
          "group flex items-start gap-3 text-[15px] leading-5 text-newt-text-secondary",
          disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
          checked && !disabled && "text-newt-text-primary",
          className
        )}
      >
        {/*
         * The input keeps every behaviour it was born with and loses only its
         * appearance; the box beside it is the drawing.
         */}
        <input
          ref={ref}
          type="checkbox"
          name={group.name}
          value={value}
          checked={checked}
          disabled={disabled}
          onChange={() => group.toggle(value)}
          className="peer sr-only"
          {...props}
        />
        <span
          aria-hidden="true"
          data-checked={checked || undefined}
          className={cn(
            "mt-px flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border-2 border-newt-text-muted transition-colors duration-fast ease-newt",
            "data-[checked]:border-newt-brand data-[checked]:bg-newt-brand",
            "peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-newt-text-link",
            !disabled && "group-hover:border-newt-text-secondary"
          )}
        >
          <svg
            viewBox="0 0 16 16"
            className="h-3.5 w-3.5 scale-0 text-white transition-transform duration-fast ease-newt group-has-[:checked]:scale-100"
          >
            <path
              fill="currentColor"
              d="M6.2 11.6 3.1 8.5a1 1 0 0 1 1.4-1.4l1.7 1.7 4.3-4.3a1 1 0 1 1 1.4 1.4l-5 5a1 1 0 0 1-1.4 0Z"
            />
          </svg>
        </span>
        <span className="flex min-w-0 flex-col gap-0.5">
          <span>{children}</span>
          {description === undefined ? null : (
            <span className="text-[13px] leading-[17px] text-newt-text-muted">
              {description}
            </span>
          )}
        </span>
      </label>
    )
  }
)
CheckboxOption.displayName = "CheckboxOption"

export { CheckboxGroup, CheckboxOption }
<script setup lang="ts">
import { computed, provide, ref, useId, watch, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { checkboxGroupKey } from "."

/*
 * Several answers out of a visible list, where a radio group takes one. The
 * group owns the set of chosen values; each row is a real `<input>`, so the
 * space bar and the label association come for free and only the box is
 * redrawn.
 */
const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /** Controlled selection (use with v-model). */
    modelValue?: readonly string[]
    defaultValue?: readonly string[]
    /** Accessible name for the group. */
    label?: string
    /** Shared `name` for the inputs. One is generated when it is not given. */
    name?: string
  }>(),
  { modelValue: undefined, defaultValue: () => [] }
)

const emit = defineEmits<{
  (e: "update:modelValue", value: readonly string[]): void
}>()

const internal = ref<readonly string[]>(props.modelValue ?? props.defaultValue)
watch(
  () => props.modelValue,
  (next) => {
    if (next !== undefined) internal.value = next
  }
)

const values = computed(() => props.modelValue ?? internal.value)
const generated = useId()
const name = computed(() => props.name ?? generated)

function toggle(value: string) {
  const next = values.value.includes(value)
    ? values.value.filter((each) => each !== value)
    : [...values.value, value]
  if (props.modelValue === undefined) internal.value = next
  emit("update:modelValue", next)
}

provide(checkboxGroupKey, { values, toggle, name })
</script>

<template>
  <div
    role="group"
    :aria-label="props.label"
    :class="cn('flex flex-col gap-2', props.class)"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import { computed, inject, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { checkboxGroupKey } from "."

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /** What this row stands for in the group's value. */
    value: string
    /** The quieter line under the label. */
    description?: string
    disabled?: boolean
  }>(),
  { disabled: false }
)

const group = inject(checkboxGroupKey)
if (group === undefined) {
  throw new Error("<CheckboxOption> must be used inside a <CheckboxGroup>.")
}

const checked = computed(() => group.values.value.includes(props.value))
</script>

<template>
  <label
    :class="
      cn(
        'group flex items-start gap-3 text-[15px] leading-5 text-newt-text-secondary',
        props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
        checked && !props.disabled && 'text-newt-text-primary',
        props.class
      )
    "
  >
    <!--
      The input keeps every behaviour it was born with and loses only its
      appearance; the box beside it is the drawing.
    -->
    <input
      type="checkbox"
      class="peer sr-only"
      :name="group.name.value"
      :value="props.value"
      :checked="checked"
      :disabled="props.disabled"
      @change="group.toggle(props.value)"
    />
    <span
      aria-hidden="true"
      :data-checked="checked ? true : undefined"
      :class="
        cn(
          'mt-px flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border-2 border-newt-text-muted transition-colors duration-fast ease-newt',
          'data-[checked]:border-newt-brand data-[checked]:bg-newt-brand',
          'peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-newt-text-link',
          !props.disabled && 'group-hover:border-newt-text-secondary'
        )
      "
    >
      <svg
        viewBox="0 0 16 16"
        class="h-3.5 w-3.5 scale-0 text-white transition-transform duration-fast ease-newt group-has-[:checked]:scale-100"
      >
        <path
          fill="currentColor"
          d="M6.2 11.6 3.1 8.5a1 1 0 0 1 1.4-1.4l1.7 1.7 4.3-4.3a1 1 0 1 1 1.4 1.4l-5 5a1 1 0 0 1-1.4 0Z"
        />
      </svg>
    </span>
    <span class="flex min-w-0 flex-col gap-0.5">
      <span><slot /></span>
      <span
        v-if="props.description"
        class="text-[13px] leading-[17px] text-newt-text-muted"
      >
        {{ props.description }}
      </span>
    </span>
  </label>
</template>
import type { InjectionKey, Ref } from "vue"

export { default as CheckboxGroup } from "./CheckboxGroup.vue"
export { default as CheckboxOption } from "./CheckboxOption.vue"

/** What a row needs from the group it sits in. */
export interface CheckboxGroupContext {
  readonly values: Ref<readonly string[]>
  readonly toggle: (value: string) => void
  readonly name: Ref<string>
}

export const checkboxGroupKey: InjectionKey<CheckboxGroupContext> = Symbol(
  "newt-checkbox-group"
)

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { CheckboxGroup } from "@/components/ui/checkbox-group"

export function Example() {
  return <CheckboxGroup />
}
<script setup lang="ts">
import { CheckboxGroup, CheckboxOption } from "@/components/ui/checkbox-group"
</script>

<template>
  <CheckboxGroup />
</template>

API reference

CheckboxGroup

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Checkbox Group reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.