FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Skeleton

Skeleton

pnpm dlx shadcn@latest add …/r/skeleton.json

A React skeleton loader for Next.js and Tailwind CSS: placeholder blocks while content loads, with a calm sheen that sweeps every skeleton in step.

  • Loading profile
    Profile
  • Loading post
    Card
import { Skeleton } from "@/components/ui/skeleton"

export function ProfileLoading() {
  return (
    <div aria-busy="true" className="flex items-center gap-3">
      <span className="sr-only">Loading profile</span>
      <Skeleton className="size-10 rounded-full" />
      <div className="grid gap-2">
        <Skeleton className="h-4 w-40" />
        <Skeleton className="h-4 w-24" />
      </div>
    </div>
  )
}
placeholders.tsx
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";

/*
 * A profile row and a card, each named underneath. Every skeleton sweeps in step. The skeletons are hidden from
 * screen readers; each loading region says so with aria-busy and a visually hidden label.
 */
export default function SkeletonPlaceholders() {
  return (
    <ul className="flex w-full flex-wrap items-end justify-center gap-x-12 gap-y-8">
      <li className="flex flex-col items-center gap-3">
        <div aria-busy="true" className="flex w-64 items-center gap-3">
          <span className="sr-only">Loading profile</span>
          <Skeleton className="size-10 shrink-0 rounded-full" />
          <div className="grid flex-1 gap-2">
            <Skeleton className="h-4 w-3/4" />
            <Skeleton className="h-4 w-1/2" />
          </div>
        </div>
        <span className="text-xs text-muted-foreground">Profile</span>
      </li>
      <li className="flex flex-col items-center gap-3">
        <Card aria-busy="true" className="w-64">
          <span className="sr-only">Loading post</span>
          <CardHeader>
            <Skeleton className="h-4 w-2/3" />
            <Skeleton className="h-4 w-1/2" />
          </CardHeader>
          <CardContent>
            <Skeleton className="aspect-video w-full" />
          </CardContent>
        </Card>
        <span className="text-xs text-muted-foreground">Card</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/skeleton.tsx
    import { SkeletonSheen } from "@/components/ui/skeleton-sheen";
    import { cn } from "@/lib/utils";
    import type { ComponentProps } from "react";
    
    /*
     * A placeholder block while content loads (owner's roadmap, 2026-10-16), with shadcn's API: one `div` you shape
     * with classes (`h-4 w-40`, `size-10 rounded-full`).
     * - A muted block with a calm sheen sweeping across it: one neutral hue, a step lighter, never a colour. The sheen
     *   lives in SkeletonSheen, a small client part, so Skeleton stays a server component, which is where loading
     *   states usually render (loading.tsx, Suspense fallbacks).
     * - Every skeleton on the page sweeps in step, however late it mounted (see skeleton-sheen.tsx).
     * - With reduced motion it's a still, muted block.
     * - Hidden from screen readers (aria-hidden): a grey block says nothing. Mark the region that's loading with
     *   aria-busy="true" and a visually hidden "Loading…" instead (the demos do).
     */
    
    function Skeleton({ className, children, ...props }: ComponentProps<"div">) {
      return (
        <div
          data-slot="skeleton"
          aria-hidden
          className={cn("relative overflow-hidden rounded-md bg-muted", className)}
          {...props}
        >
          {children}
          <SkeletonSheen />
        </div>
      );
    }
    
    export { Skeleton };
    components/ui/skeleton-sheen.tsx
    "use client";
    
    import { duration, ease } from "@/lib/motion";
    import { useReducedMotion } from "motion/react";
    import { useEffect, useRef } from "react";
    
    /*
     * The light sweeping across a Skeleton. The only client part of Skeleton, so Skeleton stays server-safe.
     * - How: a band (a gradient one step lighter than the block, fading out at both sides) sits just outside the
     *   block and slides across it. Only `translate` moves, so the browser composites it on its own.
     * - In step: every sheen counts from the page's start (startTime 0 on the document timeline), so skeletons that
     *   mount at different moments still sweep together, as one loading surface.
     * - The pace: a 1.5s sweep, then a 0.5s rest, on ease.inOut. Calm, not a flicker.
     * - With reduced motion nothing moves: a still, muted block. Before hydration it's still too; the sweep starts
     *   once React runs.
     * - The Web Animations API directly, rather than motion: motion doesn't expose an animation's start time, and
     *   that's what keeps every sheen in step.
     */
    
    const period = duration.slower * 4 * 1000;
    /** The share of each period spent sweeping; the rest is a pause */
    const sweep = 0.75;
    
    function SkeletonSheen() {
      const reduce = useReducedMotion() ?? false;
      const ref = useRef<HTMLSpanElement>(null);
    
      useEffect(() => {
        const node = ref.current;
        if (!node || reduce || typeof node.animate !== "function") return;
        // From the start of the line in either writing direction
        const [from, to] = getComputedStyle(node).direction === "rtl" ? ["100%", "-100%"] : ["-100%", "100%"];
        const animation = node.animate(
          [
            { translate: from, easing: `cubic-bezier(${ease.inOut.join(", ")})` },
            { translate: to, offset: sweep },
            { translate: to },
          ],
          { duration: period, iterations: Number.POSITIVE_INFINITY },
        );
        animation.startTime = 0;
        return () => animation.cancel();
      }, [reduce]);
    
      return (
        <span
          ref={ref}
          aria-hidden
          data-slot="skeleton-sheen"
          className="pointer-events-none absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-background/60 to-transparent rtl:translate-x-full dark:via-foreground/5"
        />
      );
    }
    
    export { SkeletonSheen };
  5. 5

    Update the import paths to match your project.

Examples

Loading, then content

Reload: the skeleton fades in while the region is aria-busy, then the content comes back through the blur in the same place.

SD
Sofia DavisDesign lead, Lisbon
loading.tsx
"use client";

import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { blur, duration, ease } from "@/lib/motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";

/*
 * Reload: the profile gives way to its skeleton, and comes back through the blur when it has "loaded". Both sit
 * in one grid cell, so nothing below moves. The skeleton only fades (it's a fill), the content sharpens in.
 * While it loads, the region is aria-busy and a status says so.
 */
export default function SkeletonLoading() {
  const [loading, setLoading] = useState(false);
  const reduce = useReducedMotion() ?? false;

  // Stands in for a request
  useEffect(() => {
    if (!loading) return;
    const timer = setTimeout(() => setLoading(false), 1500);
    return () => clearTimeout(timer);
  }, [loading]);

  return (
    <div className="grid w-full max-w-xs justify-items-start gap-6">
      <div aria-busy={loading} className="grid w-full">
        <AnimatePresence initial={false}>
          {loading ? (
            <motion.div
              key="skeleton"
              className="col-start-1 row-start-1 flex items-center gap-3"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: duration.fast, ease: ease.out }}
            >
              <Skeleton className="size-10 shrink-0 rounded-full" />
              <div className="grid flex-1 gap-2">
                <Skeleton className="h-4 w-3/4" />
                <Skeleton className="h-4 w-1/2" />
              </div>
            </motion.div>
          ) : (
            <motion.div
              key="profile"
              className="col-start-1 row-start-1 flex items-center gap-3"
              initial={{ opacity: 0, filter: reduce ? blur.shown : blur.hidden }}
              animate={{ opacity: 1, filter: blur.shown }}
              exit={{ opacity: 0, transition: { duration: duration.fast, ease: ease.in } }}
              transition={{ duration: duration.base, ease: ease.out }}
            >
              <Avatar size="lg">
                <AvatarFallback>SD</AvatarFallback>
              </Avatar>
              <div className="grid min-w-0">
                <span className="truncate text-sm font-medium">Sofia Davis</span>
                <span className="truncate text-sm text-muted-foreground">Design lead, Lisbon</span>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <span role="status" className="sr-only">
        {loading ? "Loading profile" : ""}
      </span>
      <Button variant="outline" size="sm" onClick={() => setLoading(true)}>
        Reload
      </Button>
    </div>
  );
}

API reference

A muted block you shape with classes, for example "h-4 w-40" or "size-10 rounded-full". Hidden from screen readers: put aria-busy="true" and a visually hidden "Loading…" on the region that's loading. Also accepts every native div prop.

PropTypeDefaultDescription
className?string–Its size and shape. Rounded md by default; rounded-full for a circle.

Related components

  • Spinner

    An accessible React spinner for Next.js and Tailwind CSS: one line going round an infinity that fills it once the task is done.

  • Team plan
    8 of 10 seats used
    Active
    Card

    A React card for Next.js and Tailwind CSS: a surface for grouped content with header, action, content and footer parts, a small size and a link style.

  • OMJLIN+3
    Avatar

    An accessible React avatar for Next.js and Tailwind CSS: a picture or initials in three sizes, alone or in an overlapping group, built on Radix Avatar.