Glass empty states
Five frosted translucent empty panels that vary the blur, the tint and the depth: a neutral frost, a primary tint, a dark smoke, a clear crystal and a layered depth panel. Each is sized, token-driven, renders with default copy and reads best over a background.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/empty-glassDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/empty-glass.tsx"use client"
import type * as React from "react"
import { Box, Gem, Inbox, Layers, Sparkles } from "lucide-react"
import { cn } from "@/lib/utils"
import { glassDepth } from "@/components/ui/glass"
/* Glass empty family: 5 frosted / translucent empty-state panels. The glass
surface derives from the glassDepth scale inside @ai2/glass (no hand-written
backdrop-blur), so they all carry the ai2 inset highlight signature; the
difference is the depth step, the tint and a light detail specific to the
variant. Color comes ONLY from semantic tokens, with transparency via
color-mix. size = padding + icon/text scale. Renders with no props too. Static -
no motion. Note: the panel only makes sense sitting on top of a background.
Depth map (a large panel sitting in the page flow):
Crystal -> sm (4px) clear glass + a thin light line on top
Frost -> md (8px) the canonical default
Tint -> md (8px) the same glass as Frost, differing in its info hue
Smoke -> lg (16px) dense smoke
Depth -> xl (24px) a floating layered panel
The tint surfaces are written as bg-[color-mix(...)], NOT [background:...]:
only bg-* is in the same tailwind-merge group as the scale's bg-background/N
and replaces it cleanly; since [background:...] is a separate group both would
survive and the winner would be decided by stylesheet order rather than class
order. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Scale = {
root: string
icon: string
badge: string
title: string
description: string
}
const scales: Record<StyledSize, Scale> = {
sm: {
root: "gap-2 p-6",
icon: "size-6",
badge: "size-12",
title: "text-sm",
description: "text-xs",
},
md: {
root: "gap-3 p-8",
icon: "size-8",
badge: "size-16",
title: "text-base",
description: "text-sm",
},
lg: {
root: "gap-3.5 p-10",
icon: "size-10",
badge: "size-20",
title: "text-lg",
description: "text-sm",
},
xl: {
root: "gap-4 p-12",
icon: "size-12",
badge: "size-24",
title: "text-xl",
description: "text-base",
},
}
interface EmptyProps {
className?: string
size?: StyledSize
icon?: React.ReactNode
title?: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
}
const rootBase =
"relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border text-center"
const iconBase = "[&_i]:leading-none [&_svg]:size-full"
/* Shared glass shell: the panel class carries the variant's technique. */
function GlassShell({
panelClassName,
badgeClassName,
fallbackIcon,
className,
size = "md",
icon,
title,
description,
action,
children,
}: EmptyProps & {
panelClassName: string
badgeClassName: string
fallbackIcon: React.ReactNode
children?: React.ReactNode
}) {
const s = scales[size]
return (
<div
data-slot="styled-empty"
className={cn(rootBase, panelClassName, s.root, className)}
>
{children}
<span
aria-hidden="true"
className={cn(
"relative flex items-center justify-center rounded-full border",
badgeClassName,
s.badge
)}
>
<span className={cn(iconBase, s.icon)}>{icon ?? fallbackIcon}</span>
</span>
<p className={cn("relative font-semibold text-foreground", s.title)}>
{title}
</p>
{description ? (
<p
className={cn(
"relative max-w-prose text-muted-foreground",
s.description
)}
>
{description}
</p>
) : null}
{action ? <div className="relative mt-2">{action}</div> : null}
</div>
)
}
const neutralBadge =
"text-muted-foreground [background:color-mix(in_oklab,var(--color-foreground)_8%,transparent)] [border-color:color-mix(in_oklab,var(--color-border)_70%,transparent)]"
/* FrostEmpty: the canonical default glass (md/8px). The ground is now the scale's own bg-background/60 - the variant only softens the border. */
export function FrostEmpty({
title = "Nothing here yet",
description = "This frosted panel is waiting for its first item.",
...props
}: EmptyProps) {
return (
<GlassShell
panelClassName={cn(
glassDepth.md,
"[border-color:color-mix(in_oklab,var(--color-border)_60%,transparent)]"
)}
badgeClassName={neutralBadge}
fallbackIcon={<Inbox />}
title={title}
description={description}
{...props}
/>
)
}
/* TintEmpty: the SAME md step as Frost; what distinguishes it is the info hue, not the blur. (Because primary/brand carries ~0.005 chroma, a "primary tint" would be invisible.) The icon colour was also moved from text-primary to text-info: inside an info-toned badge, primary read as neutral, so the tint was inconsistent. */
export function TintEmpty({
title = "Your collection is empty",
description = "Add something and it will appear on this tinted panel.",
...props
}: EmptyProps) {
return (
<GlassShell
panelClassName={cn(
glassDepth.md,
"bg-[color-mix(in_oklab,var(--color-info)_10%,transparent)]",
"supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-info)_10%,transparent)]",
"[border-color:color-mix(in_oklab,var(--color-info)_25%,transparent)]"
)}
badgeClassName="text-info [background:color-mix(in_oklab,var(--color-info)_16%,transparent)] [border-color:color-mix(in_oklab,var(--color-info)_30%,transparent)]"
fallbackIcon={<Sparkles />}
title={title}
description={description}
{...props}
/>
)
}
/* SmokeEmpty: lg (16px). It used to be xl; a large panel sits on a flat page ground, and with nothing to diffuse the 24px cost a compositor layer without adding anything visually. */
export function SmokeEmpty({
title = "No activity to show",
description = "Events will surface through the smoke as they happen.",
...props
}: EmptyProps) {
return (
<GlassShell
panelClassName={cn(
glassDepth.lg,
"bg-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
"supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
"[border-color:color-mix(in_oklab,var(--color-foreground)_18%,transparent)]"
)}
badgeClassName={neutralBadge}
fallbackIcon={<Layers />}
title={title}
description={description}
{...props}
/>
)
}
/* CrystalEmpty: sm (4px) - this file was already on the right step with blur-sm, and now the same value comes from the scale together with the signature. Its character is the thin light line on top. */
export function CrystalEmpty({
title = "A clean slate",
description = "Nothing is stored here yet. Whatever you add shows up first.",
...props
}: EmptyProps) {
return (
<GlassShell
panelClassName={cn(
glassDepth.sm,
"[border-color:color-mix(in_oklab,var(--color-border)_50%,transparent)]"
)}
badgeClassName={neutralBadge}
fallbackIcon={<Gem />}
title={title}
description={description}
{...props}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-6 top-0 h-px [background:linear-gradient(to_right,transparent,color-mix(in_oklab,var(--color-foreground)_30%,transparent),transparent)]"
/>
</GlassShell>
)
}
/* DepthEmpty: xl (24px) - the only variant in the family meant to read as a
genuinely floating panel; this is the one place that earns the depth.
The old shadow-lg + [box-shadow:inset...] pair was REMOVED. They were in the
same tailwind-merge group, so shadow-lg was already dropping silently; worse,
the remaining [box-shadow:...] would now take the place of the glassDepth
signature. The depth is now told through the scale's ambient drop plus a radial
light coming from below. */
export function DepthEmpty({
title = "No items in this space",
description = "This layered panel fills up as soon as you create something.",
...props
}: EmptyProps) {
return (
<GlassShell
panelClassName={cn(
glassDepth.xl,
"[border-color:color-mix(in_oklab,var(--color-border)_60%,transparent)]"
)}
badgeClassName={neutralBadge}
fallbackIcon={<Box />}
title={title}
description={description}
{...props}
>
<span
aria-hidden="true"
/* The light derives from foreground: in this theme --color-primary resolves to BYTE-IDENTICAL the same value as --color-foreground, so a "primary glow" was both an invisible claim and a wrong signal. Primary is a role token (active/selected/focus), not a decorative colour. */
className="pointer-events-none absolute inset-0 [background:radial-gradient(120%_80%_at_50%_120%,color-mix(in_oklab,var(--color-foreground)_14%,transparent),transparent_70%)]"
/>
</GlassShell>
)
}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
A neutral frosted panel with a medium backdrop blur.
Nothing here yet
This frosted panel is waiting for its first item.
import { FrostEmpty } from "@/components/ui/empty-glass"
<FrostEmpty />Nothing here yet
This frosted panel is waiting for its first item.
Nothing here yet
This frosted panel is waiting for its first item.
Nothing here yet
This frosted panel is waiting for its first item.
Nothing here yet
This frosted panel is waiting for its first item.
Tint
A primary-tinted translucent panel.
Your collection is empty
Add something and it will appear on this tinted panel.
import { TintEmpty } from "@/components/ui/empty-glass"
<TintEmpty />Your collection is empty
Add something and it will appear on this tinted panel.
Your collection is empty
Add something and it will appear on this tinted panel.
Your collection is empty
Add something and it will appear on this tinted panel.
Your collection is empty
Add something and it will appear on this tinted panel.
Smoke
A dark smoky panel with a strong blur.
No activity to show
Events will surface through the smoke as they happen.
import { SmokeEmpty } from "@/components/ui/empty-glass"
<SmokeEmpty />No activity to show
Events will surface through the smoke as they happen.
No activity to show
Events will surface through the smoke as they happen.
No activity to show
Events will surface through the smoke as they happen.
No activity to show
Events will surface through the smoke as they happen.
Crystal
A very clear panel with a light top edge.
A clean slate
Nothing is stored here yet. Whatever you add shows up first.
import { CrystalEmpty } from "@/components/ui/empty-glass"
<CrystalEmpty />A clean slate
Nothing is stored here yet. Whatever you add shows up first.
A clean slate
Nothing is stored here yet. Whatever you add shows up first.
A clean slate
Nothing is stored here yet. Whatever you add shows up first.
A clean slate
Nothing is stored here yet. Whatever you add shows up first.
Depth
A layered panel with an inner highlight and a soft glow.
No items in this space
This layered panel fills up as soon as you create something.
import { DepthEmpty } from "@/components/ui/empty-glass"
<DepthEmpty />No items in this space
This layered panel fills up as soon as you create something.
No items in this space
This layered panel fills up as soon as you create something.
No items in this space
This layered panel fills up as soon as you create something.
No items in this space
This layered panel fills up as soon as you create something.
ai2 Glass empty states: 5 styled variations on the token system
The ai2 Glass empty states are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around frosted translucent empty-state panels. 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: these are static; no motion library work is required. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, there is no motion to reduce.
What is in the ai2 Glass empty states?
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: these are static; no motion library work is required.
- Reduced-motion aware: Under prefers-reduced-motion, there is no motion to reduce.
- 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 empty states 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.