Glass buttons
Five takes on frosted surfaces: classic frost, a primary tint, clear crystal, dark smoke and floating depth. Each is sized and token-driven, with a solid fallback where backdrop-filter is unsupported.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/buttons-glassDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/buttons-glass.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react"
import { cn } from "@/lib/utils"
import { glassDepth } from "@/components/ui/glass"
/* Glass button family: 5 variations on a frosted/translucent surface theme. All
of them are sized, use framer-motion, take color ONLY from semantic tokens
(transparency via color-mix), and are reduced-motion safe. If backdrop-filter
is unsupported they fall back to an opaque surface.
The surface is no longer hand-made: it derives from the glassDepth scale inside
@ai2/glass (AGENTS.md 4.5). This file holds SMALL controls (h-8..h-12), so the
scale is deliberately shifted DOWN: sm/md carry the body, lg is reserved for
Depth alone since that is the one leaving the plane, and xl is never used
(there is not enough content behind a button for a 24px blur to diffuse).
twMerge trap: glassDepth carries a [box-shadow:...] (the ai2 signature = top
inner highlight + ambient shadow). A second shadow from the same group CANNOT
be written, it silently deletes the signature. Extra depth is therefore applied
with drop-shadow (the filter group); the signature stays intact in box-shadow.
The tint hue is info: primary/brand sits on the neutral ramp (chroma ~0.005),
so a "primary tint" would stay invisible and would give the same pixels as
Smoke. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const sizes: Record<StyledSize, string> = {
sm: "h-8 gap-1.5 rounded-md px-3 text-sm",
md: "h-9 gap-2 rounded-lg px-4 text-sm",
lg: "h-10 gap-2 rounded-lg px-5 text-sm",
xl: "h-12 gap-2.5 rounded-xl px-6 text-base",
}
const base =
"relative inline-flex shrink-0 select-none items-center justify-center font-medium whitespace-nowrap text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
type Props = HTMLMotionProps<"button"> & { size?: StyledSize }
/* Frost: classic frosted glass. Depth = md (8px), the same canonical default as glass.overlay. A button is this family's "normal" surface; departing from it needs a reason, and Frost has none. Its character comes entirely from the library signature: the inner top highlight plus a neutral border. */
export function FrostButton({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<motion.button
data-slot="styled-button"
whileHover={reduce ? undefined : { scale: 1.03 }}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={{ type: "spring", stiffness: 350, damping: 22 }}
className={cn(
base,
sizes[size],
glassDepth.md,
"border border-border",
className
)}
{...props}
>
{children}
</motion.button>
)
}
/* Tint: translucent glass colored with info. Depth = sm (4px). The tint's job is
to carry color; the more the blur grows the more neutral the content behind it
becomes, and the hue piles onto itself and turns muddy. 4px keeps the hue
clear.
bg DELIBERATELY overrides the lib's bg-background (the tint is the surface
itself); the supports- variant is overridden too, otherwise in a browser that
supports backdrop-filter the lib's supports-[backdrop-filter]:bg-background/50
would take the tint back. */
export function TintButton({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<motion.button
data-slot="styled-button"
whileHover={reduce ? undefined : { scale: 1.03 }}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={{ type: "spring", stiffness: 350, damping: 22 }}
className={cn(
base,
sizes[size],
glassDepth.sm,
"border border-info/30 bg-[color-mix(in_oklab,var(--color-info)_16%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-info)_16%,transparent)]",
className
)}
{...props}
>
{children}
</motion.button>
)
}
/* Crystal: clear glass. Depth = sm (4px) - the LEAST diffusing in the family. It used to be blur-lg (16px), which contradicted its own name: being "clear" requires being able to see what is behind. The edge highlight is no longer hand-made but the library signature itself; writing a second inset on top would erase the signature. Crystal takes its character from a brighter border that foregrounds the signature. */
export function CrystalButton({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<motion.button
data-slot="styled-button"
whileHover={reduce ? undefined : { scale: 1.03 }}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={{ type: "spring", stiffness: 350, damping: 22 }}
className={cn(
base,
sizes[size],
glassDepth.sm,
"border border-[color-mix(in_oklab,var(--color-foreground)_20%,transparent)]",
className
)}
{...props}
>
{children}
</motion.button>
)
}
/* Smoke: smoky glass darkened with foreground. Depth = md (8px). Smoke means
diffusion, so it sits one step above Crystal/Tint; but behind an h-9 button
there is not enough content for 16px to diffuse, and there the blur would
become an invisible cost. The surface color is foreground: what separates it
from Tint is the absence of hue (neutral smoke) - and that distinction only
works because Tint is info; had it been primary the two would be identical
pixels. */
export function SmokeButton({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<motion.button
data-slot="styled-button"
whileHover={reduce ? undefined : { scale: 1.03 }}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={{ type: "spring", stiffness: 350, damping: 22 }}
className={cn(
base,
sizes[size],
glassDepth.md,
"border border-foreground/10 bg-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
className
)}
{...props}
>
{children}
</motion.button>
)
}
/* Depth: layered glass - a floating feel. Depth = lg (16px) and the DEEPEST step
in this file. The reason is behavior, not size: this is the only variation in
the family that leaves the plane (y:-2 on hover), and content to actually
diffuse forms underneath a rising surface. It does not go up to xl - 24px is
wasted on a button.
The extra shadow is applied with drop-shadow (the filter group), because
box-shadow belongs to the lib signature: writing shadow-lg there would silently
delete it. */
export function DepthButton({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<motion.button
data-slot="styled-button"
whileHover={reduce ? undefined : { y: -2, scale: 1.02 }}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={{ type: "spring", stiffness: 350, damping: 22 }}
className={cn(
base,
sizes[size],
glassDepth.lg,
"border border-border drop-shadow-lg",
className
)}
{...props}
>
{children}
</motion.button>
)
}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
Classic frosted glass from surface tokens with a top highlight.
import { FrostButton } from "@/components/ui/buttons-glass"
<FrostButton>Frost</FrostButton>Tint
Translucent glass tinted with the primary token.
import { TintButton } from "@/components/ui/buttons-glass"
<TintButton>Tint</TintButton>Crystal
Sharper edge highlights and a stronger blur read as clear glass.
import { CrystalButton } from "@/components/ui/buttons-glass"
<CrystalButton>Crystal</CrystalButton>Smoke
Darkened translucent glass tinted with the foreground token.
import { SmokeButton } from "@/components/ui/buttons-glass"
<SmokeButton>Smoke</SmokeButton>Depth
Layered glass with a strong shadow that floats on hover.
import { DepthButton } from "@/components/ui/buttons-glass"
<DepthButton>Depth</DepthButton>ai2 Glass buttons: 5 styled variations on the token system
The ai2 Glass buttons are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around frosted, translucent surfaces. 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 adds a spring on hover and tap; the depth variant also floats up. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the springs are disabled and the glass surfaces stay still.
What is in the ai2 Glass buttons?
5 exports in one file: Frost, Tint, Crystal, Smoke 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 adds a spring on hover and tap; the depth variant also floats up.
- Reduced-motion aware: Under prefers-reduced-motion, the springs are disabled and the glass surfaces stay still.
- 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 buttons 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.