Checkbox Group
Adjacent checked rows spring into a continuous selection fill — opacity eases through the snappy preset so a multi-select reads as a single band instead of a scatter of ticks.
Custom
Usage
Email
Push notifications
SMS
Phone calls
checkbox-group.svelte
<script lang="ts">
import { CheckboxGroup } from '$lib/components/ui/checkbox-group';
const items = [
{ value: 'email', label: 'Email' },
{ value: 'push', label: 'Push notifications' },
{ value: 'sms', label: 'SMS' }
];
let value = $state<string[]>(['email']);
</script>
<CheckboxGroup {items} bind:value />With descriptions
Weekly digest Summary of activity every Monday morning.
Mentions Someone tags you in a thread or comment.
Security alerts New sign-ins, password changes, and device approvals.
checkbox-group.svelte
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
import { cn } from '$lib/utils.js';
export const checkboxGroupVariants = tv({
base: 'flex flex-col',
variants: {
size: {
sm: 'text-sm',
default: 'text-sm',
lg: 'text-base'
}
},
defaultVariants: {
size: 'default'
}
});
export type CheckboxGroupSize = VariantProps<typeof checkboxGroupVariants>['size'];
export type CheckboxGroupItem = {
value: string;
label: string;
description?: string;
disabled?: boolean;
};
export type CheckboxGroupProps = {
items: CheckboxGroupItem[];
value?: string[];
size?: CheckboxGroupSize;
class?: string;
onchange?: (value: string[]) => void;
ref?: HTMLDivElement | null;
/** Accessible name for the group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the group. Set this or `label`. */
labelledby?: string;
};
</script>
<script lang="ts">
import { animate } from 'motion';
import { springs, prefersReducedMotion, proximity } from '$lib/motion';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
let {
items = [],
value = $bindable([]),
size = 'default',
class: className,
onchange,
ref = $bindable(null),
label,
labelledby
}: CheckboxGroupProps = $props();
// Track which items are checked
let checked = $state<Record<string, boolean>>({});
// Init from value prop
$effect(() => {
const next: Record<string, boolean> = {};
for (const item of items) {
next[item.value] = value.includes(item.value);
}
checked = next;
});
function toggle(itemValue: string) {
checked[itemValue] = !checked[itemValue];
const next = items.filter((i) => checked[i.value]).map((i) => i.value);
value = next;
onchange?.(next);
}
// Determine border-radius per item based on adjacency of checked neighbors
function getItemRadius(index: number): string {
const isChecked = checked[items[index].value];
if (!isChecked) return 'rounded-lg';
const prevChecked = index > 0 && checked[items[index - 1].value];
const nextChecked = index < items.length - 1 && checked[items[index + 1].value];
if (prevChecked && nextChecked) return 'rounded-none';
if (prevChecked) return 'rounded-t-none rounded-b-lg';
if (nextChecked) return 'rounded-t-lg rounded-b-none';
return 'rounded-lg';
}
// Spring-animate the bg fill on each item row
let rowRefs = $state<(HTMLDivElement | null)[]>([]);
$effect(() => {
for (let i = 0; i < items.length; i++) {
const el = rowRefs[i];
if (!el) continue;
const isChecked = checked[items[i].value];
const transition = prefersReducedMotion() ? { duration: 0 } : springs.snappy;
animate(el, { opacity: isChecked ? 1 : 0 }, transition);
}
});
</script>
<div
bind:this={ref}
data-slot="checkbox-group"
role="group"
aria-label={label}
aria-labelledby={labelledby}
class={cn(checkboxGroupVariants({ size }), className)}
use:proximity={{ selector: '[data-proximity-target]', maxDistance: 80 }}
>
{#each items as item, i (item.value)}
{@const isChecked = checked[item.value]}
{@const prevChecked = i > 0 && checked[items[i - 1].value]}
{@const nextChecked = i < items.length - 1 && checked[items[i + 1].value]}
<div
data-slot="checkbox-group-item"
data-proximity-target
data-checked={isChecked ? '' : undefined}
class={cn(
'relative flex cursor-pointer items-start gap-3 px-3 py-2.5 select-none before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-[background-color] before:duration-(--motion-duration-fast) before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*25%),transparent)]',
item.disabled && 'cursor-not-allowed opacity-50'
)}
>
<!-- Spring-animated merged bg fill layer -->
<div
bind:this={rowRefs[i]}
aria-hidden="true"
class={cn(
'absolute inset-0 bg-primary/8 dark:bg-primary/15',
getItemRadius(i),
// Collapse dividing border between adjacent checked items
isChecked && prevChecked && '-mt-px',
isChecked && nextChecked && '-mb-px'
)}
style="opacity: 0;"
></div>
<!-- Checkbox control (shared primitive for visual parity with <Checkbox>) -->
<div class="relative z-10 mt-0.5 flex shrink-0 items-center">
<Checkbox
checked={isChecked}
aria-label={item.label}
disabled={item.disabled}
onCheckedChange={() => !item.disabled && toggle(item.value)}
/>
</div>
<!-- Label + description -->
<div
class="relative z-10 flex flex-col gap-0.5"
onclick={() => !item.disabled && toggle(item.value)}
onkeydown={(e) => {
if ((e.key === ' ' || e.key === 'Enter') && !item.disabled) {
e.preventDefault();
toggle(item.value);
}
}}
role="presentation"
>
<span class="leading-snug font-medium text-foreground">{item.label}</span>
{#if item.description}
<span class="text-xs leading-snug text-muted-foreground">{item.description}</span>
{/if}
</div>
</div>
{/each}
</div>
States
Option A
Option B Disabled row keeps its layout.
Option C
checkbox-group.svelte
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
import { cn } from '$lib/utils.js';
export const checkboxGroupVariants = tv({
base: 'flex flex-col',
variants: {
size: {
sm: 'text-sm',
default: 'text-sm',
lg: 'text-base'
}
},
defaultVariants: {
size: 'default'
}
});
export type CheckboxGroupSize = VariantProps<typeof checkboxGroupVariants>['size'];
export type CheckboxGroupItem = {
value: string;
label: string;
description?: string;
disabled?: boolean;
};
export type CheckboxGroupProps = {
items: CheckboxGroupItem[];
value?: string[];
size?: CheckboxGroupSize;
class?: string;
onchange?: (value: string[]) => void;
ref?: HTMLDivElement | null;
/** Accessible name for the group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the group. Set this or `label`. */
labelledby?: string;
};
</script>
<script lang="ts">
import { animate } from 'motion';
import { springs, prefersReducedMotion, proximity } from '$lib/motion';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
let {
items = [],
value = $bindable([]),
size = 'default',
class: className,
onchange,
ref = $bindable(null),
label,
labelledby
}: CheckboxGroupProps = $props();
// Track which items are checked
let checked = $state<Record<string, boolean>>({});
// Init from value prop
$effect(() => {
const next: Record<string, boolean> = {};
for (const item of items) {
next[item.value] = value.includes(item.value);
}
checked = next;
});
function toggle(itemValue: string) {
checked[itemValue] = !checked[itemValue];
const next = items.filter((i) => checked[i.value]).map((i) => i.value);
value = next;
onchange?.(next);
}
// Determine border-radius per item based on adjacency of checked neighbors
function getItemRadius(index: number): string {
const isChecked = checked[items[index].value];
if (!isChecked) return 'rounded-lg';
const prevChecked = index > 0 && checked[items[index - 1].value];
const nextChecked = index < items.length - 1 && checked[items[index + 1].value];
if (prevChecked && nextChecked) return 'rounded-none';
if (prevChecked) return 'rounded-t-none rounded-b-lg';
if (nextChecked) return 'rounded-t-lg rounded-b-none';
return 'rounded-lg';
}
// Spring-animate the bg fill on each item row
let rowRefs = $state<(HTMLDivElement | null)[]>([]);
$effect(() => {
for (let i = 0; i < items.length; i++) {
const el = rowRefs[i];
if (!el) continue;
const isChecked = checked[items[i].value];
const transition = prefersReducedMotion() ? { duration: 0 } : springs.snappy;
animate(el, { opacity: isChecked ? 1 : 0 }, transition);
}
});
</script>
<div
bind:this={ref}
data-slot="checkbox-group"
role="group"
aria-label={label}
aria-labelledby={labelledby}
class={cn(checkboxGroupVariants({ size }), className)}
use:proximity={{ selector: '[data-proximity-target]', maxDistance: 80 }}
>
{#each items as item, i (item.value)}
{@const isChecked = checked[item.value]}
{@const prevChecked = i > 0 && checked[items[i - 1].value]}
{@const nextChecked = i < items.length - 1 && checked[items[i + 1].value]}
<div
data-slot="checkbox-group-item"
data-proximity-target
data-checked={isChecked ? '' : undefined}
class={cn(
'relative flex cursor-pointer items-start gap-3 px-3 py-2.5 select-none before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-[background-color] before:duration-(--motion-duration-fast) before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*25%),transparent)]',
item.disabled && 'cursor-not-allowed opacity-50'
)}
>
<!-- Spring-animated merged bg fill layer -->
<div
bind:this={rowRefs[i]}
aria-hidden="true"
class={cn(
'absolute inset-0 bg-primary/8 dark:bg-primary/15',
getItemRadius(i),
// Collapse dividing border between adjacent checked items
isChecked && prevChecked && '-mt-px',
isChecked && nextChecked && '-mb-px'
)}
style="opacity: 0;"
></div>
<!-- Checkbox control (shared primitive for visual parity with <Checkbox>) -->
<div class="relative z-10 mt-0.5 flex shrink-0 items-center">
<Checkbox
checked={isChecked}
aria-label={item.label}
disabled={item.disabled}
onCheckedChange={() => !item.disabled && toggle(item.value)}
/>
</div>
<!-- Label + description -->
<div
class="relative z-10 flex flex-col gap-0.5"
onclick={() => !item.disabled && toggle(item.value)}
onkeydown={(e) => {
if ((e.key === ' ' || e.key === 'Enter') && !item.disabled) {
e.preventDefault();
toggle(item.value);
}
}}
role="presentation"
>
<span class="leading-snug font-medium text-foreground">{item.label}</span>
{#if item.description}
<span class="text-xs leading-snug text-muted-foreground">{item.description}</span>
{/if}
</div>
</div>
{/each}
</div>
API reference
Custom primitive (not a bits-ui wrapper). Rows are tracked internally; value is the bindable string[] of selected keys.
| Prop | Type | Default | Description |
|---|---|---|---|
CheckboxGroupItem[] | — | Array of { value, label, description?, disabled? }. Order controls the fill-merge adjacency logic. | |
string[] (bindable) | [] | Currently selected values. Two-way bindable. | |
'sm' | 'default' | 'lg' | 'default' | Text scale for label and description rows. | |
(value: string[]) => void | — | Fires with the next selected values after every toggle. | |
HTMLDivElement | null | null | Two-way-bindable element reference on the group root. | |
string | — | Merged onto the group root via tailwind-merge. |