Skip to content
edmi-ui

Button Group

Joins related buttons, inputs and text into one control with shared borders.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Amount
1,000 USDC
Amount
1,000 USDC
Amount
1,000 USDC
Terminal window
npx shadcn@latest add @edmi-ui/button-group
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/button-group
Terminal window
yarn dlx shadcn@latest add @edmi-ui/button-group
Terminal window
bunx shadcn@latest add @edmi-ui/button-group

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/button-group
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/button-group
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/button-group
Terminal window
bunx shadcn-vue@latest add @edmi-ui/button-group

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/button-group.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/button-group.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/button-group.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/button-group.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@/components/ui/button-group";
<div className="flex flex-wrap items-start gap-6">
<ButtonGroup>
<Button variant="outline">Archive</Button>
<Button variant="outline">Report</Button>
<Button variant="outline">Snooze</Button>
</ButtonGroup>
<ButtonGroup>
<Button>Join</Button>
<ButtonGroupSeparator />
<Button size="icon" aria-label="More">
<CaretDownIcon />
</Button>
</ButtonGroup>
<ButtonGroup>
<ButtonGroupText>Amount</ButtonGroupText>
<ButtonGroupText>1,000 USDC</ButtonGroupText>
</ButtonGroup>
<ButtonGroup orientation="vertical">
<Button variant="outline" size="icon">
+
</Button>
<Button variant="outline" size="icon">
-
</Button>
</ButtonGroup>
</div>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@/components/ui/button-group";
</script>
<template>
<div class="flex flex-wrap items-start gap-6">
<ButtonGroup>
<Button variant="outline">Archive</Button>
<Button variant="outline">Report</Button>
<Button variant="outline">Snooze</Button>
</ButtonGroup>
<ButtonGroup>
<Button>Join</Button>
<ButtonGroupSeparator />
<Button size="icon" aria-label="More">
<ChevronDown />
</Button>
</ButtonGroup>
<ButtonGroup>
<ButtonGroupText>Amount</ButtonGroupText>
<ButtonGroupText>1,000 USDC</ButtonGroupText>
</ButtonGroup>
<ButtonGroup orientation="vertical">
<Button variant="outline" size="icon">+</Button>
<Button variant="outline" size="icon">-</Button>
</ButtonGroup>
</div>
</template>
<script lang="ts">
import * as ButtonGroup from "$lib/components/ui/button-group";
import { Button } from "$lib/components/ui/button";
</script>
<div class="flex flex-wrap items-start gap-6">
<ButtonGroup.Root>
<Button variant="outline">Archive</Button>
<Button variant="outline">Report</Button>
<Button variant="outline">Snooze</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button>Join</Button>
<ButtonGroup.Separator />
<Button size="icon" aria-label="More">▾</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<ButtonGroup.Text>Amount</ButtonGroup.Text>
<ButtonGroup.Text>1,000 USDC</ButtonGroup.Text>
</ButtonGroup.Root>
<ButtonGroup.Root orientation="vertical">
<Button variant="outline" size="icon">+</Button>
<Button variant="outline" size="icon">-</Button>
</ButtonGroup.Root>
</div>
registry/ui/button-group.tsx
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
import { ButtonElevationContext } from "@/registry/edmi/ui/button";
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
import { Separator } from "@/registry/edmi/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>[data-variant=default]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,var(--primary-foreground)_25%,var(--primary))] [&>[data-variant=brand]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,var(--brand-foreground)_25%,var(--brand))] [&>[data-variant=destructive]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,white_25%,var(--destructive))] *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input]]:shadow-none has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
elevation,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof buttonGroupVariants> & {
/** ✦ raised: each item is raised; floating: the whole group floats as one plate (items stay raised). */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "button-filled");
return (
<ButtonElevationContext.Provider
value={
elevation && elevation !== "auto"
? level === "floating"
? "raised"
: level
: undefined
}
>
{/* biome-ignore lint/a11y/useSemanticElements: stock markup; a div group avoids fieldset layout quirks */}
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(
buttonGroupVariants({ orientation }),
level === "floating" && "rounded-lg shadow-group-float",
className,
)}
{...props}
/>
</ButtonElevationContext.Provider>
);
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border border-input bg-muted px-2.5 text-[13px] font-medium text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
),
},
props,
),
render,
state: {
slot: "button-group-text",
},
});
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
registry/ui/button-group/ButtonGroup.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ButtonGroupVariants } from '.'
import { provide } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { BUTTON_ELEVATION_KEY } from '@/registry/edmi/ui/button'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
import { buttonGroupVariants } from '.'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
orientation?: ButtonGroupVariants['orientation']
/** ✦ raised: each item is raised; floating: the whole group floats as one plate (items stay raised) */
elevation?: Elevation
}>(), { elevation: undefined })
const level = useElevation(() => props.elevation, 'button-filled')
// items follow the group only when it has an explicit level (floating group => raised items)
provide(BUTTON_ELEVATION_KEY, {
get value() {
if (!props.elevation || props.elevation === 'auto') return undefined
return level.value === 'floating' ? 'raised' : level.value
},
})
</script>
<template>
<div
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:class="cn(buttonGroupVariants({ orientation: props.orientation }), level === 'floating' && 'rounded-lg shadow-group-float', props.class)"
>
<slot />
</div>
</template>
registry/ui/button-group/ButtonGroupSeparator.vue
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/registry/edmi/lib/utils'
import { Separator } from '@/registry/edmi/ui/separator'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'vertical',
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="button-group-separator"
v-bind="delegatedProps"
:orientation="props.orientation"
:class="cn(
'relative self-stretch bg-input data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto',
props.class,
)"
/>
</template>
registry/ui/button-group/ButtonGroupText.vue
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
interface Props extends PrimitiveProps {
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'div',
})
</script>
<template>
<Primitive
data-slot="button-group-text"
:as="as"
:as-child="asChild"
:class="cn(`flex items-center gap-2 rounded-lg border border-input bg-muted px-2.5 text-[13px] font-medium text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4`, props.class)"
>
<slot />
</Primitive>
</template>
registry/ui/button-group/index.ts
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as ButtonGroup } from "./ButtonGroup.vue";
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue";
export { default as ButtonGroupText } from "./ButtonGroupText.vue";
export const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>[data-variant=default]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,var(--primary-foreground)_25%,var(--primary))] [&>[data-variant=brand]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,var(--brand-foreground)_25%,var(--brand))] [&>[data-variant=destructive]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,white_25%,var(--destructive))] *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=input]]:shadow-none [&>[data-slot=input-group]]:shadow-none",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
export type ButtonGroupVariants = VariantProps<typeof buttonGroupVariants>;
src/lib/registry/ui/button-group/button-group.svelte
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const buttonGroupVariants = tv({
base: "group/button-group flex w-fit items-stretch [&>[data-variant=default]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,var(--primary-foreground)_25%,var(--primary))] [&>[data-variant=brand]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,var(--brand-foreground)_25%,var(--brand))] [&>[data-variant=destructive]+[data-slot=button-group-separator]]:bg-[color-mix(in_srgb,white_25%,var(--destructive))] *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
variants: {
orientation: {
horizontal: "*:data-slot:rounded-r-none [&>[data-slot=input]]:shadow-none [&>[data-slot=input-group]]:shadow-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical: "flex-col [&>[data-slot=input]]:shadow-none [&>[data-slot=input-group]]:shadow-none *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
</script>
<script lang="ts">
import { setContext } from "svelte";
import { BUTTON_ELEVATION_CONTEXT } from "$lib/registry/ui/button/index.js";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
orientation = "horizontal",
elevation = "auto",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
/** ✦ raised: each item is raised; floating: the whole group floats as one plate (items stay raised). */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "button-filled");
// items follow the group only when it has an explicit level (floating group => raised items)
setContext(BUTTON_ELEVATION_CONTEXT, () => {
if (!elevation || elevation === "auto") return undefined;
return level.current === "floating" ? "raised" : level.current;
});
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
class={cn(buttonGroupVariants({ orientation }), level.current === "floating" && "rounded-lg shadow-group-float", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/button-group/button-group-separator.svelte
<script lang="ts">
import { Separator } from "$lib/registry/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="button-group-separator"
{orientation}
class={cn(
"relative self-stretch bg-input data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
className
)}
{...restProps}
/>
src/lib/registry/ui/button-group/button-group-text.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
child,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
...restProps,
class: cn(
"flex items-center gap-2 rounded-lg border border-input bg-muted px-2.5 text-[13px] font-medium text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
"data-slot": "button-group-text",
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
src/lib/registry/ui/button-group/index.ts
import Root, {
type ButtonGroupOrientation,
buttonGroupVariants,
} from "./button-group.svelte";
import Separator from "./button-group-separator.svelte";
import Text from "./button-group-text.svelte";
export {
type ButtonGroupOrientation,
buttonGroupVariants,
Root,
//
Root as ButtonGroup,
Separator,
Separator as ButtonGroupSeparator,
Text,
Text as ButtonGroupText,
};

See the Elevation guide for the levels, roles and layered mode.

elevation="raised" raises every item. elevation="floating" floats the whole group as one plate (a single soft drop); the items stay raised, never one drop per item. Buttons inside can still override with their own elevation.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

Raised (+1)

Floating (+2)

Flat (0)

Raised (+1)

Floating (+2)

Flat (0)

Raised (+1)

Floating (+2)

Prop Type Default
orientation horizontal | vertical horizontal
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"

Parts: ButtonGroup, ButtonGroupSeparator, ButtonGroupText (render supported).

  • Same API as shadcn; depends on the separator item.