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-groupnpx newtui@latest add checkbox-groupyarn dlx newtui@latest add checkbox-groupbunx newtui@latest add checkbox-groupCopy 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
children / default slot?React.ReactNode / Vue slot
Content rendered inside the component.
React.ReactNode / Vue slotclassName / class?string
Additional classes merged with the component defaults.
stringAccessibility
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.