Skeleton
A shaped placeholder that holds layout while content loads.
Installation
Terminal
pnpm dlx @dowel-ui/cli add skeletonNo npm packages are needed beyond what Dowel already requires.
Accessibility
Hidden from assistive technology. Put aria-busy on the container that owns the loading data so the state is announced once instead of once per placeholder. Pulse and shimmer are decoration and stop under reduced motion.
Props
Skeleton
| Prop | Type | Default |
|---|---|---|
variant
| keyof typeof variants | "pulse" |
Plus every attribute of <span>.
Quality
8/8 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
Source
This is exactly what dowel add skeleton writes into your project, with imports rewritten to your own path alias.
ui/skeleton.tsx
// Motion from SmoothUI SkeletonLoader (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils";
const PREFIX = "dowel-skeleton";
/* The highlight travels from the inline start to the inline end. The gradient
* is twice the element's width, so moving its position from 150% to -50%
* carries the bright band from just outside one edge to just outside the other. */
const STYLES = `
@keyframes ${PREFIX}-shimmer{from{background-position:150% 0}to{background-position:-50% 0}}
[data-slot=skeleton][data-variant=shimmer]:dir(rtl){animation-direction:reverse}
`;
/* A plain map rather than cva: one axis of two values does not justify adding a
* dependency to a component that had none. */
const variants = {
pulse: "animate-pulse-soft",
// Spelled out because Tailwind reads class names from the source.
shimmer: cn(
"bg-[linear-gradient(90deg,transparent_30%,color-mix(in_oklab,var(--color-background)_55%,transparent)_50%,transparent_70%)]",
"bg-[length:200%_100%] bg-no-repeat",
"animate-[dowel-skeleton-shimmer_calc(1.6s*var(--motion-scale))_linear_infinite]",
),
} as const;
export interface SkeletonProps extends ComponentPropsWithRef<"span"> {
/** `pulse` fades in and out; `shimmer` sweeps a band of light across. */
variant?: keyof typeof variants;
}
/**
* Placeholder shown while content loads.
*
* Rendered aria-hidden: the shape is meaningless to a screen reader, and the
* loading state belongs on the region that owns the data (via aria-busy) rather
* than on each individual placeholder.
*
* A `span` set to `display: block`, not a `div`. A skeleton stands in for
* whatever was going to be there, so it gets placed inside paragraphs, headings
* and labels as often as inside layout containers — and a `div` inside a `p` is
* invalid HTML that the parser corrects, which breaks hydration rather than
* merely looking wrong. Phrasing content is valid in both places, and `block`
* keeps the box behaviour identical.
*
* `variant="shimmer"` swaps the pulse for a band of light sweeping across. Both
* are decoration: under reduced motion they stop and the placeholder is a plain
* block.
*/
export function Skeleton({ className, variant = "pulse", ...props }: SkeletonProps) {
const shimmer = variant === "shimmer";
return (
<>
{shimmer ? (
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
) : null}
<span
data-slot="skeleton"
data-variant={shimmer ? "shimmer" : undefined}
aria-hidden="true"
className={cn("block rounded-md bg-muted", variants[variant], className)}
{...props}
/>
</>
);
}