Glass toggle groups
Five single-select toggle groups built on a translucent shell: a backdrop blur plus a low-opacity token fill lets the page read through, and the active indicator is itself a glass bubble. Only the tone and depth of the glass change. Where backdrop-filter is unsupported each falls back to an opaque token surface. Self-contained, sized and token-driven.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/toggle-group-glassDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/toggle-group-glass.tsx"use client"
import * as React from "react"
import { Cloud, Moon, Sun } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
import { glassDepth } from "@/components/ui/glass"
/* Glass toggle group family: 5 decorative frosted-glass single-select controls. The
glass is NO LONGER hand-written: the shell derives from the glassDepth scale
inside @ai2/glass, so the ai2 signature (top inset highlight + ambient shadow) is
the same language in every variant.
Choosing the depth (a bar-shaped control): a segmented group is wide but SHORT
(h-8..h-11) and sits in the page flow; behind a short bar a heavy blur brings no
gain, so the scale starts low and only the genuinely floating variant reaches the
top.
NO GLASS ON GLASS: the glass is ONLY on the shell. The active indicator used to be
described as "a bubble of glass"; it is now an OPAQUE highlight on top of the bar -
a second translucent layer would have turned to mud, and it was blurring the glass
itself rather than the content behind it anyway. Since the indicator does not
carry glassDepth, using shadow-* on it is safe.
The variants change the tone and the depth of the glass (neutral ice, info tint,
smoky dark, sharp crystal, layered depth). Color comes ONLY from semantic tokens;
alpha via color-mix or the tailwind opacity modifier. The framer layoutId
indicator moves instantly while useReducedMotion is on; the layoutId is unique per
instance via React.useId(). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Option = { value: string; label?: React.ReactNode; icon?: React.ReactNode }
type ToggleGroupProps = {
className?: string
size?: StyledSize
type?: "single" | "multiple"
options?: Option[]
value?: string | string[]
defaultValue?: string | string[]
onValueChange?: (v: string | string[]) => void
}
const sizeBtn: Record<StyledSize, string> = {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 text-sm",
lg: "h-10 px-5 text-sm",
xl: "h-11 px-6 text-base",
}
const btnBase =
"relative z-10 inline-flex select-none items-center justify-center gap-2 whitespace-nowrap font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const THEME_OPTIONS: Option[] = [
{ value: "light", label: "Light", icon: <Sun /> },
{ value: "dim", label: "Dim", icon: <Cloud /> },
{ value: "dark", label: "Dark", icon: <Moon /> },
]
function toArray(v: string | string[] | undefined): string[] {
if (v === undefined) return []
return Array.isArray(v) ? v : [v]
}
/* Kontrollu/kontrolsuz secim durumu. Tek-secimde varsayilan ilk secenektir. */
function useToggleGroup(props: ToggleGroupProps, fallback: Option[]) {
const { type = "single", options, value, defaultValue, onValueChange } = props
const list = React.useMemo(() => (options && options.length ? options : fallback), [options, fallback])
const controlled = value !== undefined
const [internal, setInternal] = React.useState<string[]>(() => {
const init = toArray(defaultValue)
if (init.length === 0 && type === "single" && defaultValue === undefined) {
return list[0] ? [list[0].value] : []
}
return init
})
const selected = controlled ? toArray(value) : internal
const emit = React.useCallback(
(next: string[]) => {
if (!controlled) setInternal(next)
onValueChange?.(type === "single" ? (next[0] ?? "") : next)
},
[controlled, onValueChange, type]
)
const toggle = React.useCallback(
(v: string) => {
const on = selected.includes(v)
if (type === "single") {
emit(on ? [] : [v])
} else {
emit(on ? selected.filter((s) => s !== v) : [...selected, v])
}
},
[selected, type, emit]
)
const isOn = React.useCallback((v: string) => selected.includes(v), [selected])
return { list, isOn, toggle }
}
type Spec = {
/** The glass shell class. */
root: string
/** Aktif dugme metin rengi. */
onText: string
/** Kapali dugme metin rengi. */
offText: string
/** The glass indicator class. */
indicator: string
/** Erisilebilir ad. */
label: string
}
/* The shared shell: only the tone and the depth of the glass change. */
function GlassGroup({ props, spec }: { props: ToggleGroupProps; spec: Spec }) {
const { className, size = "md" } = props
const { list, isOn, toggle } = useToggleGroup({ ...props, type: "single" }, THEME_OPTIONS)
const id = React.useId()
const reduce = useReducedMotion()
const transition = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 34 }
return (
<div
data-slot="styled-toggle-group-viewport"
className="-m-1 flex max-w-full overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div
data-slot="styled-toggle-group"
role="group"
aria-label={spec.label}
className={cn("inline-flex items-center gap-0.5 rounded-xl border p-1", spec.root, className)}
>
{list.map((o) => {
const on = isOn(o.value)
return (
<button
key={o.value}
type="button"
aria-pressed={on}
data-slot="styled-toggle-group-item"
onClick={() => toggle(o.value)}
className={cn(btnBase, sizeBtn[size], "rounded-lg", on ? spec.onText : spec.offText)}
>
{on && (
<motion.span
layoutId={`${id}-glass-indicator`}
transition={transition}
className={cn("absolute inset-0 -z-10 rounded-lg", spec.indicator)}
/>
)}
{o.icon}
{o.label}
</button>
)
})}
</div>
</div>
)
}
/* Frost: neutral ice. DEPTH sm (4px): the plainest shell; behind a short segmented
bar anything more is an invisible cost.
The indicator: an opaque background chip (the old supports-[...]:bg-background/70
translucency was removed - a highlight on glass must be opaque). */
export function FrostToggleGroup(props: ToggleGroupProps) {
return (
<GlassGroup
props={props}
spec={{
label: "Theme, frosted glass",
root: cn(glassDepth.sm, "border-border/60"),
onText: "text-foreground",
offText: "text-muted-foreground hover:text-foreground",
indicator: "border border-border/70 bg-background shadow-sm",
}}
/>
)
}
/* Tint: info-toned glass. DEPTH md (8px, the canonical default): the character comes
from the TONE, not the blur. Tint overrides both the bg-* and the supports- step.
The indicator: an OPAQUE mix of info + background; text-primary (the semantic role
of the active state) reads clearly on it. */
export function TintToggleGroup(props: ToggleGroupProps) {
return (
<GlassGroup
props={props}
spec={{
label: "Theme, info tinted glass",
root: cn(glassDepth.md, "border-info/25 bg-info/10 supports-[backdrop-filter]:bg-info/10"),
onText: "text-primary",
offText: "text-muted-foreground hover:text-primary",
indicator:
"border border-info/30 bg-[color-mix(in_oklab,var(--color-info)_18%,var(--color-background))] shadow-sm",
}}
/>
)
}
/* Smoke: smoky dark glass. DEPTH md (8px): the same step as Tint DELIBERATELY -
glass at the same distance, differing only in tone (a foreground smoke vs info).
The indicator: a bright opaque chip cutting through the smoke. */
export function SmokeToggleGroup(props: ToggleGroupProps) {
return (
<GlassGroup
props={props}
spec={{
label: "Theme, smoked glass",
root: cn(
glassDepth.md,
"border-[color-mix(in_oklab,var(--color-foreground)_14%,transparent)]",
"bg-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]",
"supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]"
),
onText: "text-foreground",
offText: "text-muted-foreground hover:text-foreground",
indicator:
"bg-background shadow-sm ring-1 ring-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]",
}}
/>
)
}
/* Crystal: a clear pane. DEPTH lg (16px): here the diffusion is the SUBJECT, not
decoration.
Note: the `shadow-sm` on the root was removed - it would have overridden the same
CSS property as the glassDepth signature; the bright top edge is drawn with
border-t. */
export function CrystalToggleGroup(props: ToggleGroupProps) {
return (
<GlassGroup
props={props}
spec={{
label: "Theme, crystal glass",
root: cn(
glassDepth.lg,
"border-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)]",
"border-t-[color-mix(in_oklab,var(--color-background)_85%,transparent)]",
"supports-[backdrop-filter]:backdrop-saturate-150"
),
onText: "text-foreground",
offText: "text-muted-foreground hover:text-foreground",
indicator:
"bg-background shadow-sm ring-1 ring-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)] after:absolute after:inset-x-2 after:top-0 after:h-px after:rounded-full after:bg-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
}}
/>
)
}
/* Depth: layered depth. DEPTH xl (24px): the ONLY variant that earns the top of the
scale - it stands like a lifted overlay bar, and the further it moves from the
surface the more what is behind it diffuses.
Note: the `shadow-sm` on the root was removed (it would have overridden the
signature), and the separation comes from a ring instead. A gradient removes the
bg-* step but does NOT remove the supports-* step, so it is explicitly pulled back
to bg-transparent. Since the indicator does not carry glassDepth, shadow-md is
safe there - it is what carries the sense of depth. */
export function DepthToggleGroup(props: ToggleGroupProps) {
return (
<GlassGroup
props={props}
spec={{
label: "Theme, layered depth glass",
root: cn(
glassDepth.xl,
"border-border/50",
"bg-gradient-to-b supports-[backdrop-filter]:bg-transparent",
"from-[color-mix(in_oklab,var(--color-background)_55%,transparent)]",
"to-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]",
"ring-1 ring-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)]"
),
onText: "text-foreground",
offText: "text-muted-foreground hover:text-foreground",
indicator: "border border-border/60 bg-background shadow-md",
}}
/>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Frost
Neutral ice on a surface token with a soft edge.
import { FrostToggleGroup } from "@/components/ui/toggle-group-glass"
<FrostToggleGroup />Tint
Primary tinted glass with a denser indicator bubble.
import { TintToggleGroup } from "@/components/ui/toggle-group-glass"
<TintToggleGroup />Smoke
Smoked glass that mutes whatever sits behind it.
import { SmokeToggleGroup } from "@/components/ui/toggle-group-glass"
<SmokeToggleGroup />Crystal
Almost colourless, a bright top edge and a strong blur.
import { CrystalToggleGroup } from "@/components/ui/toggle-group-glass"
<CrystalToggleGroup />Depth
A gradient shell with a lifted, shadowed indicator.
import { DepthToggleGroup } from "@/components/ui/toggle-group-glass"
<DepthToggleGroup />ai2 Glass toggle groups: 5 styled variations on the token system
The ai2 Glass toggle groups are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around frosted, translucent toggle groups with a blurred backdrop. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion slides the glass indicator to the active option. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the indicator changes position instantly with no animation.
What is in the ai2 Glass toggle groups?
5 exports in one file: Frost, Tint, Smoke, Crystal and Depth. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion slides the glass indicator to the active option.
- Reduced-motion aware: Under prefers-reduced-motion, the indicator changes position instantly with no animation.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Glass toggle groups sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.