HextaUI

Skeleton

Placeholders that wait 150ms before showing, take the exact size of the content they wrap, and fade it in without moving anything.

import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonDemo() {
  return (
    <div className="flex w-full max-w-sm items-center gap-4">
      <Skeleton className="size-12 rounded-full" />
      <div className="flex flex-1 flex-col gap-2">
        <Skeleton className="h-4 w-3/5" />
        <Skeleton className="h-4 w-4/5" />
      </div>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/skeleton.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

The shimmer and pulse use two animations from your theme. Add them to your global CSS file once.

@import "tw-animate-css";

@theme {
  --animate-shimmer: shimmer 1.6s ease-in-out infinite;
  --animate-skeleton-pulse: skeleton-pulse 2s ease-in-out infinite;

  @keyframes shimmer {
    from {
      translate: calc(-100% * var(--skeleton-dir, 1)) 0;
    }
    to {
      translate: calc(100% * var(--skeleton-dir, 1)) 0;
    }
  }

  @keyframes skeleton-pulse {
    0%,
    100% {
      opacity: 0;
    }
    50% {
      opacity: 0.5;
    }
  }
}

Use a skeleton on its own as a sized placeholder, or pass loading and wrap the real content so the placeholder takes its exact size.

import { Skeleton, SkeletonText } from "@/components/ui/skeleton"
<Skeleton className="h-4 w-32" />

<Skeleton loading={isLoading}>
  <h3>{user.name}</h3>
</Skeleton>

Shapes

On its own, a skeleton is an empty block. Give it a size and radius with classes to match what it stands in for.

import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonShapes() {
  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <div className="flex items-center gap-4">
        <Skeleton className="size-12 rounded-full" />
        <div className="flex flex-1 flex-col gap-2">
          <Skeleton className="h-4 w-2/5" />
          <Skeleton className="h-4 w-3/5" />
        </div>
        <Skeleton className="h-8 w-20" />
      </div>
      <Skeleton className="aspect-video w-full rounded-xl" />
    </div>
  )
}

Animations

animation picks a sweeping shimmer, a soft pulse, or none.

shimmer
pulse
none
import { Skeleton, type SkeletonAnimation } from "@/components/ui/skeleton"

const animations: SkeletonAnimation[] = ["shimmer", "pulse", "none"]

export function SkeletonAnimations() {
  return (
    <div className="grid w-full max-w-md grid-cols-3 gap-3">
      {animations.map((animation) => (
        <div key={animation} className="flex flex-col gap-2">
          <Skeleton animation={animation} className="h-16 w-full" />
          <span className="text-xs text-muted-foreground">{animation}</span>
        </div>
      ))}
    </div>
  )
}

Text

<SkeletonText /> draws one bar per line and follows the parent’s font size and line height, so it fills the same space as the text it replaces. The last line is shorter.

import { SkeletonText } from "@/components/ui/skeleton"

export function SkeletonTextDemo() {
  return (
    <div className="grid w-full max-w-md grid-cols-3 gap-6">
      <div className="text-sm">
        <SkeletonText lines={3} />
      </div>
      <div className="text-lg">
        <SkeletonText lines={3} />
      </div>
      <div className="text-sm leading-8">
        <SkeletonText lines={3} />
      </div>
    </div>
  )
}

Wrap real content

With loading, the skeleton renders the real content invisibly underneath, so it takes the exact size and nothing shifts when the data arrives. When loading turns false, the content fades in.

"use client"

import * as React from "react"
import { IconUserPlus } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonWrapContent() {
  const [loading, setLoading] = React.useState(true)

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex items-start gap-4 rounded-xl border p-4">
        <Skeleton loading={loading} className="rounded-full">
          <img
            src="/preview/landscape.svg"
            alt=""
            className="size-12 rounded-full object-cover"
          />
        </Skeleton>
        <div className="flex min-w-0 flex-1 flex-col gap-1">
          <Skeleton loading={loading}>
            <h3 className="font-semibold">Olivia Martin</h3>
          </Skeleton>
          <Skeleton loading={loading}>
            <p className="text-sm text-muted-foreground">
              Design engineer at Acme. Writes about motion and small details.
            </p>
          </Skeleton>
        </div>
        <Skeleton loading={loading}>
          <Button size="sm" variant="outline">
            <IconUserPlus data-icon="inline-start" />
            Follow
          </Button>
        </Skeleton>
      </div>
      <div className="flex gap-2">
        <Button size="sm" onClick={() => setLoading(false)}>
          Load
        </Button>
        <Button size="sm" variant="ghost" onClick={() => setLoading(true)}>
          Reset
        </Button>
      </div>
    </div>
  )
}

Inline

Pass render={<span />} to place a skeleton inside a sentence. It sits on the text baseline.

Your balance is as of today.

import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonInline() {
  return (
    <p className="text-sm">
      Your balance is{" "}
      <Skeleton loading render={<span />}>
        <strong>$12,480.00</strong>
      </Skeleton>{" "}
      as of today.
    </p>
  )
}

Fast load

A skeleton stays invisible for its first 150ms, then fades in. Data that arrives sooner never flashes a placeholder.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonFastLoad() {
  const [loading, setLoading] = React.useState(true)
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  function run() {
    clearTimeout(timer.current)
    setLoading(true)
    timer.current = setTimeout(() => setLoading(false), 80)
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Skeleton loading={loading}>
        <p className="text-sm">
          Loaded in 80ms, so the skeleton never became visible.
        </p>
      </Skeleton>
      <Button size="sm" onClick={run}>
        Run fast load
      </Button>
    </div>
  )
}

Many rows

Skeletons that mount together start their animation together, so a long list reads as one loading area.

import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonManyRows() {
  return (
    <div className="flex max-h-64 w-full max-w-sm flex-col gap-3 overflow-y-auto">
      {Array.from({ length: 50 }, (_, index) => (
        <div key={index} className="flex items-center gap-3">
          <Skeleton className="size-8 rounded-full" />
          <Skeleton className="h-3 w-1/2" />
        </div>
      ))}
    </div>
  )
}

Right to left

In a right-to-left layout the shimmer sweeps from right to left.

import { Skeleton, SkeletonText } from "@/components/ui/skeleton"

export function SkeletonRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-sm items-center gap-4 text-sm">
      <Skeleton className="size-12 rounded-full" />
      <div className="flex-1">
        <SkeletonText lines={2} />
      </div>
    </div>
  )
}
  • A skeleton on its own is decorative and hidden from assistive tech, and so is <SkeletonText />.
  • A skeleton wrapping content sets aria-busy while loading. The content underneath is hidden from assistive tech and can’t be focused until it loads.
  • Skeletons don’t announce anything. When people need to know what is loading, add a visible label or a status message.
  • With reduced motion enabled, skeletons appear right away without shimmer, pulse or fade.

Accepts every attribute of the element it renders.

PropTypeDefault
animation
"shimmer" | "pulse" | "none""shimmer"
loadingWhen set, the skeleton wraps its children: true shows the placeholder, false shows the content. Leave it out for a standalone placeholder.
boolean–
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="skeleton"Target skeletons in CSS.
data-animationThe animation in use. Removed once wrapped content has loaded.
data-loading"true" or "false" when the skeleton wraps content.
data-slot="skeleton-content"Wraps the real content. Invisible and inert while loading.
--skeleton-dir1, or -1 in right-to-left layouts. Sets the shimmer’s direction.

Accepts every <div> attribute.

PropTypeDefault
linesRounded down and kept between 1 and 50.
number3
animation
"shimmer" | "pulse" | "none""shimmer"
AttributeDescription
data-slot="skeleton-text"The container for the lines.