Motion empty states
Five empty states with an animated entrance: a floating badge, a staggered fade, a spring pop, a bouncy drop and a self-drawing SVG illustration. Each is sized, token-driven, deterministic and gates every transform on reduced motion.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/empty-motionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/empty-motion.tsx"use client"
import type * as React from "react"
import { CloudSun, Feather, Inbox, Sparkles } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Motion empty family: 5 empty states carrying an animated entrance. Gliding,
staggered fade, pop, bounce and an SVG draw. All of them are gated on
useReducedMotion(): in reduced mode there is no transform or progressing
animation, only content that appears instantly; DrawEmpty falls back to the
finished static shape in reduced mode. Color comes ONLY from semantic tokens
(transparency via color-mix), size = padding + icon/text scale. Deterministic:
no Date.now() or Math.random(). Renders with no props too. */
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 = "flex flex-col items-center justify-center text-center"
const iconBase = "[&_i]:leading-none [&_svg]:size-full"
const badgeBase =
"flex items-center justify-center rounded-full border text-primary [background:color-mix(in_oklab,var(--color-primary)_12%,transparent)] [border-color:color-mix(in_oklab,var(--color-primary)_28%,transparent)]"
/* FloatEmpty: rozet yavasca asagi yukari suzulur, metin fade ile girer. */
export function FloatEmpty({
className,
size = "md",
icon,
title = "Nothing floating by",
description = "New items drift in here as soon as they are created.",
action,
}: EmptyProps) {
const s = scales[size]
const reduce = useReducedMotion()
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<motion.span
aria-hidden="true"
className={cn(badgeBase, s.badge)}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={
reduce ? { opacity: 1 } : { opacity: 1, y: [0, -6, 0] }
}
transition={
reduce
? { duration: 0.2 }
: {
opacity: { duration: 0.3, ease: "easeOut" },
y: { duration: 3, ease: "easeInOut", repeat: Infinity },
}
}
>
<span className={cn(iconBase, s.icon)}>{icon ?? <Feather />}</span>
</motion.span>
<motion.p
className={cn("font-semibold text-foreground", s.title)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 0.1 }}
>
{title}
</motion.p>
{description ? (
<motion.p
className={cn("max-w-prose text-muted-foreground", s.description)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 0.18 }}
>
{description}
</motion.p>
) : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
/* FadeEmpty: the icon, title and description enter with a staggered fade. In reduced
mode there is no slide, only a fade. */
export function FadeEmpty({
className,
size = "md",
icon,
title = "This view is empty",
description = "Everything you add will fade into place right here.",
action,
}: EmptyProps) {
const s = scales[size]
const reduce = useReducedMotion()
const item = {
hidden: reduce ? { opacity: 0 } : { opacity: 0, y: 10 },
show: reduce ? { opacity: 1 } : { opacity: 1, y: 0 },
}
return (
<motion.div
data-slot="styled-empty"
className={cn(rootBase, s.root, className)}
initial="hidden"
animate="show"
variants={{
hidden: {},
show: { transition: { staggerChildren: reduce ? 0 : 0.08 } },
}}
>
<motion.span
aria-hidden="true"
className={cn(badgeBase, s.badge)}
variants={item}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<span className={cn(iconBase, s.icon)}>{icon ?? <CloudSun />}</span>
</motion.span>
<motion.p
className={cn("font-semibold text-foreground", s.title)}
variants={item}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{title}
</motion.p>
{description ? (
<motion.p
className={cn("max-w-prose text-muted-foreground", s.description)}
variants={item}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{description}
</motion.p>
) : null}
{action ? (
<motion.div className="mt-2" variants={item}>
{action}
</motion.div>
) : null}
</motion.div>
)
}
/* PopEmpty: rozet spring ile buyuyerek girer (scale pop). */
export function PopEmpty({
className,
size = "md",
icon,
title = "All caught up",
description = "There is nothing left in this list right now.",
action,
}: EmptyProps) {
const s = scales[size]
const reduce = useReducedMotion()
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<motion.span
aria-hidden="true"
className={cn(badgeBase, s.badge)}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
transition={
reduce
? { duration: 0.2 }
: { type: "spring" as const, stiffness: 420, damping: 18 }
}
>
<span className={cn(iconBase, s.icon)}>{icon ?? <Sparkles />}</span>
</motion.span>
<motion.p
className={cn("font-semibold text-foreground", s.title)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 0.12 }}
>
{title}
</motion.p>
{description ? (
<motion.p
className={cn("max-w-prose text-muted-foreground", s.description)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 0.2 }}
>
{description}
</motion.p>
) : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
/* BounceEmpty: the badge falls from above and settles with a hop (a bouncy
spring). */
export function BounceEmpty({
className,
size = "md",
icon,
title = "Your inbox is clear",
description = "New messages land here the moment they arrive.",
action,
}: EmptyProps) {
const s = scales[size]
const reduce = useReducedMotion()
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<motion.span
aria-hidden="true"
className={cn(badgeBase, s.badge)}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -28 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
transition={
reduce
? { duration: 0.2 }
: { type: "spring" as const, stiffness: 500, damping: 12, mass: 0.8 }
}
>
<span className={cn(iconBase, s.icon)}>{icon ?? <Inbox />}</span>
</motion.span>
<motion.p
className={cn("font-semibold text-foreground", s.title)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 0.16 }}
>
{title}
</motion.p>
{description ? (
<motion.p
className={cn("max-w-prose text-muted-foreground", s.description)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 0.24 }}
>
{description}
</motion.p>
) : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
/* DrawEmpty: bos kutu illustrasyonu SVG stroke ile cizilir (pathLength 0 -> 1).
Reduced modda cizim atlanir ve sekil bastan CIZILMIS/sabit gorunur. */
export function DrawEmpty({
className,
size = "md",
icon,
title = "Nothing packed in here",
description = "This box is empty. Add your first item to fill it up.",
action,
}: EmptyProps) {
const s = scales[size]
const reduce = useReducedMotion()
const draw = {
initial: { pathLength: reduce ? 1 : 0, opacity: reduce ? 1 : 0 },
animate: { pathLength: 1, opacity: 1 },
}
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<span aria-hidden="true" className={cn("text-primary", s.badge)}>
{icon ? (
<span className={cn(iconBase, "flex size-full items-center justify-center")}>
{icon}
</span>
) : (
<svg
viewBox="0 0 64 64"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="size-full"
>
{/* Kutu govdesi */}
<motion.path
d="M10 22 L32 12 L54 22 L54 46 L32 56 L10 46 Z"
initial={draw.initial}
animate={draw.animate}
transition={
reduce
? { duration: 0 }
: { duration: 1, ease: "easeInOut" }
}
/>
{/* Orta dikey ve ust catal cizgileri */}
<motion.path
d="M10 22 L32 32 L54 22 M32 32 L32 56"
initial={draw.initial}
animate={draw.animate}
transition={
reduce
? { duration: 0 }
: { duration: 0.8, ease: "easeInOut", delay: 0.6 }
}
/>
</svg>
)}
</span>
<motion.p
className={cn("font-semibold text-foreground", s.title)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 1.2 }}
>
{title}
</motion.p>
{description ? (
<motion.p
className={cn("max-w-prose text-muted-foreground", s.description)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: reduce ? 0 : 1.3 }}
>
{description}
</motion.p>
) : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}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.
Float
The badge drifts gently up and down.
Nothing floating by
New items drift in here as soon as they are created.
import { FloatEmpty } from "@/components/ui/empty-motion"
<FloatEmpty />Nothing floating by
New items drift in here as soon as they are created.
Nothing floating by
New items drift in here as soon as they are created.
Nothing floating by
New items drift in here as soon as they are created.
Nothing floating by
New items drift in here as soon as they are created.
Fade
The icon, title and description fade in one after another.
This view is empty
Everything you add will fade into place right here.
import { FadeEmpty } from "@/components/ui/empty-motion"
<FadeEmpty />This view is empty
Everything you add will fade into place right here.
This view is empty
Everything you add will fade into place right here.
This view is empty
Everything you add will fade into place right here.
This view is empty
Everything you add will fade into place right here.
Pop
The badge springs up from a small scale.
All caught up
There is nothing left in this list right now.
import { PopEmpty } from "@/components/ui/empty-motion"
<PopEmpty />All caught up
There is nothing left in this list right now.
All caught up
There is nothing left in this list right now.
All caught up
There is nothing left in this list right now.
All caught up
There is nothing left in this list right now.
Bounce
The badge drops in and settles with a bouncy spring.
Your inbox is clear
New messages land here the moment they arrive.
import { BounceEmpty } from "@/components/ui/empty-motion"
<BounceEmpty />Your inbox is clear
New messages land here the moment they arrive.
Your inbox is clear
New messages land here the moment they arrive.
Your inbox is clear
New messages land here the moment they arrive.
Your inbox is clear
New messages land here the moment they arrive.
Draw
An SVG box illustration draws itself stroke by stroke.
Nothing packed in here
This box is empty. Add your first item to fill it up.
import { DrawEmpty } from "@/components/ui/empty-motion"
<DrawEmpty />Nothing packed in here
This box is empty. Add your first item to fill it up.
Nothing packed in here
This box is empty. Add your first item to fill it up.
Nothing packed in here
This box is empty. Add your first item to fill it up.
Nothing packed in here
This box is empty. Add your first item to fill it up.
ai2 Motion empty states: 5 styled variations on the token system
The ai2 Motion empty states are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around empty states with an animated entrance. 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 animates the entrance: a float loop, a staggered fade, a spring pop, a bouncy drop and an SVG stroke draw. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transforms are skipped, the content fades in place and the drawn illustration shows fully drawn and static.
What is in the ai2 Motion empty states?
5 exports in one file: Float, Fade, Pop, Bounce and Draw. 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 animates the entrance: a float loop, a staggered fade, a spring pop, a bouncy drop and an SVG stroke draw.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are skipped, the content fades in place and the drawn illustration shows fully drawn and static.
- 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 Motion 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.