Avatar skeletons
Five avatar and text combinations: a round avatar row, a square avatar row, a stacked people list, an inline chip and a large detail block. The avatar geometry and the text beside it change; the pulse stays the same across the family.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/skeleton-avatarDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/skeleton-avatar.tsx"use client"
import type * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Avatar skeleton family: 5 avatar plus text combinations (round, square, stack, inline, detail). The difference is not in the ANIMATION but in the STRUCTURE - the avatar geometry and the text layout beside it change; the pulse is the same calm token pulse in all of them. The widths are fixed and the delays index-derived (NO Math.random). Colour comes ONLY from tokens. Skeletons are decorative: the root is aria-hidden. The pulse stops under reduced motion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Props = React.ComponentProps<"div"> & { size?: StyledSize }
/* Shared calm pulse (framer-motion, NO keyframes). */
function Block({ className, delay = 0 }: { className?: string; delay?: number }) {
const reduce = useReducedMotion()
return (
<motion.span
className={cn("block rounded-md bg-secondary", className)}
animate={reduce ? undefined : { opacity: [0.5, 1, 0.5] }}
transition={
reduce ? undefined : { duration: 1.6, ease: "easeInOut", repeat: Infinity, delay }
}
/>
)
}
const avatar: Record<StyledSize, string> = {
sm: "size-8",
md: "size-10",
lg: "size-12",
xl: "size-14",
}
const large: Record<StyledSize, string> = {
sm: "size-12",
md: "size-16",
lg: "size-20",
xl: "size-24",
}
const line: Record<StyledSize, string> = {
sm: "h-2.5",
md: "h-3",
lg: "h-3.5",
xl: "h-4",
}
const head: Record<StyledSize, string> = {
sm: "h-3.5",
md: "h-4",
lg: "h-5",
xl: "h-6",
}
const gap: Record<StyledSize, string> = {
sm: "gap-2",
md: "gap-3",
lg: "gap-3.5",
xl: "gap-4",
}
const row = "flex w-full items-center"
/* AvatarCircle: a round avatar plus a name and a second line. */
export function AvatarCircleSkeleton({ className, size = "md", ...props }: Props) {
return (
<div
data-slot="styled-skeleton"
aria-hidden="true"
className={cn(row, gap[size], className)}
{...props}
>
<Block className={cn("shrink-0 rounded-full", avatar[size])} />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Block className={cn("w-2/5", head[size])} delay={0.12} />
<Block className={cn("w-3/5", line[size])} delay={0.24} />
</div>
</div>
)
}
/* AvatarSquare: a soft-cornered square avatar plus a name and a second line. */
export function AvatarSquareSkeleton({ className, size = "md", ...props }: Props) {
return (
<div
data-slot="styled-skeleton"
aria-hidden="true"
className={cn(row, gap[size], className)}
{...props}
>
<Block className={cn("shrink-0 rounded-lg", avatar[size])} />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Block className={cn("w-1/3", head[size])} delay={0.12} />
<Block className={cn("w-2/3", line[size])} delay={0.24} />
</div>
</div>
)
}
/* Stack: a stacked list of n people (avatar plus two lines). */
export function StackSkeleton({
className,
size = "md",
count = 3,
...props
}: Props & { count?: number }) {
const widths = ["w-2/5", "w-1/3", "w-1/2", "w-2/5"]
return (
<div
data-slot="styled-skeleton"
aria-hidden="true"
className={cn("flex w-full flex-col", gap[size], className)}
{...props}
>
{Array.from({ length: count }, (_, i) => (
<div key={i} className={cn(row, gap[size])}>
<Block className={cn("shrink-0 rounded-full", avatar[size])} delay={i * 0.14} />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Block
className={cn(widths[i % widths.length], head[size])}
delay={i * 0.14 + 0.07}
/>
<Block className={cn("w-3/5", line[size])} delay={i * 0.14 + 0.14} />
</div>
</div>
))}
</div>
)
}
/* Inline: a small avatar plus one line, like an inline badge. */
export function InlineSkeleton({ className, size = "md", ...props }: Props) {
const inline: Record<StyledSize, string> = {
sm: "size-5",
md: "size-6",
lg: "size-7",
xl: "size-8",
}
return (
<div
data-slot="styled-skeleton"
aria-hidden="true"
className={cn("inline-flex items-center", gap[size], className)}
{...props}
>
<Block className={cn("shrink-0 rounded-full", inline[size])} />
<Block className={cn("w-24", line[size])} delay={0.12} />
</div>
)
}
/* Detail: buyuk avatar, isim, meta satiri ve iki satirlik biyografi. */
export function DetailSkeleton({ className, size = "md", ...props }: Props) {
return (
<div
data-slot="styled-skeleton"
aria-hidden="true"
className={cn("flex w-full items-start", gap[size], className)}
{...props}
>
<Block className={cn("shrink-0 rounded-full", large[size])} />
<div className={cn("flex min-w-0 flex-1 flex-col", gap[size])}>
<Block className={cn("w-1/2 rounded-lg", head[size])} delay={0.12} />
<Block className={cn("w-1/4", line[size])} delay={0.24} />
<Block className={cn("w-full", line[size])} delay={0.36} />
<Block className={cn("w-4/5", line[size])} delay={0.48} />
</div>
</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.
Circle
A round avatar beside a name and a line.
import { AvatarCircleSkeleton } from "@/components/ui/skeleton-avatar"
<AvatarCircleSkeleton />Square
A soft square avatar beside two lines.
import { AvatarSquareSkeleton } from "@/components/ui/skeleton-avatar"
<AvatarSquareSkeleton />Stack
A stacked list of people rows.
import { StackSkeleton } from "@/components/ui/skeleton-avatar"
<StackSkeleton />Inline
A small avatar and one line, inline.
import { InlineSkeleton } from "@/components/ui/skeleton-avatar"
<InlineSkeleton />Detail
A large avatar with name, meta and bio.
import { DetailSkeleton } from "@/components/ui/skeleton-avatar"
<DetailSkeleton />ai2 Avatar skeletons: 5 styled variations on the token system
The ai2 Avatar skeletons are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around avatar and text combinations for people rows and profile blocks. 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 runs one calm opacity pulse, staggered across the blocks. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the pulse stops and the row stays static.
What is in the ai2 Avatar skeletons?
5 exports in one file: Circle, Square, Stack, Inline and Detail. 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 runs one calm opacity pulse, staggered across the blocks.
- Reduced-motion aware: Under prefers-reduced-motion, the pulse stops and the row stays 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 Avatar skeletons 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.