Skeleton

Shows a placeholder while content is loading.

import * as stylex from "@stylexjs/stylex"
import { Skeleton } from "@/components/ui/skeleton"
import { HStack, VStack } from "@/components/ui/stack"
import { radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  avatar: { width: space.s12, height: space.s12, borderRadius: radius.full },
  title: { width: "16rem", height: space.s4 },
  line: { width: "12rem", height: space.s4 },
})

export default function SkeletonDemo() {
  return (
    <HStack gap={4} align="center">
      <Skeleton xstyle={styles.avatar} />
      <VStack gap={2}>
        <Skeleton xstyle={styles.title} />
        <Skeleton xstyle={styles.line} />
      </VStack>
    </HStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation.

Copy and paste the following code into your project.

components/ui/skeleton.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { color, ease, radius } from "@/theme/tokens.stylex"

const skeletonSheen = "light-dark(oklch(1 0 0 / 70%), oklch(1 0 0 / 6%))"

const sweep = stylex.keyframes({
  from: { backgroundPosition: "-130vw 0" },
  to: { backgroundPosition: "30vw 0" },
})

const pulse = stylex.keyframes({
  "0%": { opacity: 1 },
  "50%": { opacity: 0.5 },
  "100%": { opacity: 1 },
})

const styles = stylex.create({
  base: {
    borderRadius: radius.md,
    backgroundColor: color.muted,
    backgroundImage: {
      default: `linear-gradient(90deg, transparent 35%, ${skeletonSheen} 50%, transparent 65%)`,
      "@media (prefers-reduced-motion: reduce)": "none",
    },
    backgroundSize: "200vw 100%",
    backgroundRepeat: "no-repeat",
    backgroundAttachment: "fixed",
    animationName: {
      default: sweep,
      "@media (prefers-reduced-motion: reduce)": pulse,
    },
    animationDuration: { default: "3s", "@media (prefers-reduced-motion: reduce)": "2s" },
    animationTimingFunction: {
      default: "linear",
      "@media (prefers-reduced-motion: reduce)": ease.standard,
    },
    animationIterationCount: "infinite",
  },
})

const skeletonStyles = { base: styles.base }

type SkeletonProps = useRender.ComponentProps<"div"> & {
  xstyle?: StyleXStyles
  className?: string
}

function Skeleton({ render, ref, style, xstyle, className, ...props }: SkeletonProps) {
  const sx = stylex.props(styles.base, xstyle)

  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "skeleton",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

export { Skeleton, skeletonStyles }

Update the import paths to match your project setup.

Usage

import { Skeleton } from "@/components/ui/skeleton"
const styles = stylex.create({
  line: { width: "6rem", height: space.s5, borderRadius: radius.full },
})

<Skeleton xstyle={styles.line} />

A skeleton has no size of its own. Give it the width and height of the content it stands in for through xstyle, or through style when only the runtime knows them.

A band of light sweeps across every skeleton on the page at once. The gradient is laid out against the viewport rather than each block, so an avatar and the lines beside it share one pass. Under prefers-reduced-motion the sweep becomes a slow fade.

Avatar

import * as stylex from "@stylexjs/stylex"
import { Skeleton } from "@/components/ui/skeleton"
import { HStack, VStack } from "@/components/ui/stack"
import { radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  avatar: { width: space.s10, height: space.s10, borderRadius: radius.full },
  name: { width: "10rem", height: space.s4 },
  handle: { width: "6rem", height: space.s4 },
})

export default function SkeletonAvatar() {
  return (
    <HStack gap={4} align="center">
      <Skeleton xstyle={styles.avatar} />
      <VStack gap={2}>
        <Skeleton xstyle={styles.name} />
        <Skeleton xstyle={styles.handle} />
      </VStack>
    </HStack>
  )
}

Card

import * as stylex from "@stylexjs/stylex"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "20rem" },
  title: { width: "66.667%", height: space.s4 },
  description: { width: "50%", height: space.s4 },
  media: { width: "100%", aspectRatio: "16 / 9" },
})

export default function SkeletonCard() {
  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <Skeleton xstyle={styles.title} />
        <Skeleton xstyle={styles.description} />
      </CardHeader>
      <CardContent>
        <Skeleton xstyle={styles.media} />
      </CardContent>
    </Card>
  )
}

Text

import * as stylex from "@stylexjs/stylex"
import { Skeleton } from "@/components/ui/skeleton"
import { VStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: { width: "100%", maxWidth: "20rem" },
  line: { width: "100%", height: space.s4 },
  lastLine: { width: "75%", height: space.s4 },
})

export default function SkeletonText() {
  return (
    <VStack gap={2} xstyle={styles.root}>
      <Skeleton xstyle={styles.line} />
      <Skeleton xstyle={styles.line} />
      <Skeleton xstyle={styles.lastLine} />
    </VStack>
  )
}

Form

import * as stylex from "@stylexjs/stylex"
import { Skeleton } from "@/components/ui/skeleton"
import { VStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: { width: "100%", maxWidth: "20rem" },
  label: { width: "5rem", height: space.s4 },
  longLabel: { width: "6rem", height: space.s4 },
  control: { width: "100%", height: space.s8 },
  button: { width: "6rem", height: space.s8 },
})

export default function SkeletonForm() {
  return (
    <VStack gap={6} xstyle={styles.root}>
      <VStack gap={3}>
        <Skeleton xstyle={styles.label} />
        <Skeleton xstyle={styles.control} />
      </VStack>
      <VStack gap={3}>
        <Skeleton xstyle={styles.longLabel} />
        <Skeleton xstyle={styles.control} />
      </VStack>
      <Skeleton xstyle={styles.button} />
    </VStack>
  )
}

Table

import * as stylex from "@stylexjs/stylex"
import { Skeleton } from "@/components/ui/skeleton"
import { HStack, VStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: { width: "100%", maxWidth: "24rem" },
  name: { flexGrow: 1, height: space.s4 },
  status: { width: "6rem", height: space.s4 },
  amount: { width: "5rem", height: space.s4 },
})

export default function SkeletonTable() {
  return (
    <VStack gap={2} xstyle={styles.root}>
      {Array.from({ length: 5 }).map((_, index) => (
        <HStack gap={4} key={index}>
          <Skeleton xstyle={styles.name} />
          <Skeleton xstyle={styles.status} />
          <Skeleton xstyle={styles.amount} />
        </HStack>
      ))}
    </VStack>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Skeleton renders a <div> and accepts every <div> attribute.

Skeleton

PropTypeDefault
xstyleStyleXStyles—
styleCSSProperties—
classNamestring—

The pulse keeps running under prefers-reduced-motion: reduce. It is an opacity fade with nothing moving across the block. A still grey block would look empty rather than loading.

skeletonStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
baseThe muted fill, rounding and the pulse

Data attributes

AttributeOnEffect
data-slot="skeleton"the blockTargets the block from a parent