Button Group
Fuses adjacent Buttons into a single control. Shared border collapses between siblings and only the outer corners round — triggers stay individually keyboard-reachable.
Install
import { ButtonGroup } from '$lib/components/ui/button-group'; Basic
<script lang="ts">
import { ButtonGroup } from '$lib/components/ui/button-group';
import { Button } from '$lib/components/ui/button';
</script>
<ButtonGroup>
<Button variant="outline">Copy</Button>
<Button variant="outline">Share</Button>
<Button variant="outline">More</Button>
</ButtonGroup>Split button
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
Toolbar with separator
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
Vertical
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
Nested
A horizontal ButtonGroup can contain vertical ButtonGroup children — useful for grid-style toolbars.
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
With input
Place an Input inside a ButtonGroup to create inline search or filter controls.
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
With input group
Compose an InputGroup (with prefix text) adjacent to a Button inside a ButtonGroup.
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
With dropdown menu
Pair a primary action Button with a DropdownMenu trigger to expose overflow options — the classic
split-button pattern.
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
With select
Inline a Select trigger inside a ButtonGroup for contextual option pickers — e.g. unit type
next to a number input.
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
With popover
Open a Popover from a Button that sits inside a ButtonGroup — keeps the trigger flush with its siblings.
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button]]:relative [&>[data-slot=button]]:before:pointer-events-none [&>[data-slot=button]]:before:absolute [&>[data-slot=button]]:before:inset-0 [&>[data-slot=button]]:before:rounded-[inherit] [&>[data-slot=button]]:before:transition-[background-color] [&>[data-slot=button]]:before:duration-(--motion-duration-fast) [&>[data-slot=button]]:before:[background:color-mix(in_oklch,var(--accent)_calc(var(--proximity,0)*30%),transparent)] [&>[data-slot=button]]:before:content-['']",
variants: {
orientation: {
horizontal:
'[&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
},
radius: {
pill: '',
default: '',
sm: ''
}
},
compoundVariants: [
{
orientation: 'horizontal',
radius: 'pill',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-pill)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-pill)'
},
{
orientation: 'horizontal',
radius: 'default',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius)'
},
{
orientation: 'horizontal',
radius: 'sm',
class:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-(--radius-sm)! has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-(--radius-sm)'
},
{
orientation: 'vertical',
radius: 'pill',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-pill)!'
},
{
orientation: 'vertical',
radius: 'default',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius)!'
},
{
orientation: 'vertical',
radius: 'sm',
class: '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-(--radius-sm)!'
}
],
defaultVariants: {
orientation: 'horizontal',
radius: 'pill'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
export type ButtonGroupRadius = VariantProps<typeof buttonGroupVariants>['radius'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { proximity } from '$lib/motion/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
radius = 'pill',
label,
labelledby,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
radius?: ButtonGroupRadius;
/** Accessible name for the button group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the button group. Set this or `label`. */
labelledby?: string;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
aria-label={label}
aria-labelledby={labelledby}
class={cn(buttonGroupVariants({ orientation, radius }), className)}
use:proximity={{ selector: '[data-slot=button]', maxDistance: 80 }}
{...restProps}
>
{@render children?.()}
</div>
ButtonGroup props
ButtonGroup is a presentational wrapper. Each child stays an independent Button with its own focus ring — the group z-indexes focus so rings never clip their neighbors.
| Prop | Type | Default | Description |
|---|---|---|---|
'horizontal' | 'vertical' | 'horizontal' | Axis the children fuse along. Vertical stacks; horizontal lines them up. | |
'pill' | 'default' | 'sm' | 'pill' | Outer-corner radius token. Pill matches the signature CTA shape; default downshifts to --radius (14px) for vertical stacks and grid-style toolbars. | |
string | — | Merged onto the root via tailwind-merge. | |
HTMLDivElement | null | null | Two-way-bindable element reference. |
ButtonGroupSeparator & ButtonGroupText
Separator wraps the shared Separator primitive. Text renders a muted chip for read-only labels inside the group (e.g., a style token).
| Prop | Type | Default | Description |
|---|---|---|---|
'horizontal' | 'vertical' | 'vertical' | ButtonGroupSeparator only — sets the rule axis. | |
string | — | Passed to the rendered element. |