# Skeleton

Placeholder blocks with a soft light sweep, plus SkeletonSwap, which cross-fades to the real content with a short blur.

**Category:** Feedback  
**Exports:** `Skeleton`, `SkeletonSwap`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/skeleton.json
```
The file lands at `components/ui/skeleton.tsx`; import from `@/components/ui/skeleton`.

## API — Skeleton
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `<Skeleton />` | `ComponentProps<"div">` | — | Size it with className. |
| `<SkeletonSwap loading skeleton>` | `boolean / ReactNode` | — | Cross-fades to children when loading turns false. |

## Example — Skeleton

```tsx
import * as React from "react";
import { Skeleton, SkeletonSwap } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";

function ProfileSkeleton() {
  return (
    <div className="flex items-center gap-4">
      <Skeleton className="size-14 rounded-full" />
      <div className="grid flex-1 gap-2">
        <Skeleton className="h-4 w-2/5" />
        <Skeleton className="h-3 w-4/5" />
        <Skeleton className="h-3 w-3/5" />
      </div>
    </div>
  );
}

export default function SkeletonDemo({ name = "Maya Chen", role = "Design engineer · Lisbon" }: { name?: string; role?: string }) {
  const [loading, setLoading] = React.useState(true);
  React.useEffect(() => {
    if (!loading) return;
    const t = setTimeout(() => setLoading(false), 2200);
    return () => clearTimeout(t);
  }, [loading]);
  return (
    <div className="grid w-full max-w-sm gap-6">
      <div className="rounded-2xl border bg-background p-5">
        <SkeletonSwap loading={loading} skeleton={<ProfileSkeleton />}>
          <div className="flex items-center gap-4">
            <span className="grid size-14 place-items-center rounded-full bg-ember text-lg font-semibold text-ember-foreground">MC</span>
            <div>
              <p className="font-semibold">{name}</p>
              <p className="text-sm text-muted-foreground">{role}</p>
              <p className="text-sm text-muted-foreground">Joined October 2026</p>
            </div>
          </div>
        </SkeletonSwap>
      </div>
      <Button variant="secondary" size="sm" className="justify-self-center" onClick={() => setLoading(true)} disabled={loading}>
        Reload
      </Button>
    </div>
  );
}
```

## Source — components/ui/skeleton.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const css = `@keyframes charcoal-skeleton{from{transform:translateX(-100%)}to{transform:translateX(100%)}}`;

/** A placeholder block with a soft light sweep. Size it with className. */
export function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div aria-hidden className={cn("relative overflow-hidden rounded-md bg-muted", className)} {...props}>
      <style href="charcoal-skeleton" precedence="default">
        {css}
      </style>
      <span className="absolute inset-0 bg-gradient-to-r from-transparent via-foreground/[0.06] to-transparent [animation:charcoal-skeleton_1.4s_ease-in-out_infinite] motion-reduce:hidden" />
    </div>
  );
}

export type SkeletonSwapProps = {
  loading: boolean;
  /** What to show while loading. */
  skeleton: React.ReactNode;
  children: React.ReactNode;
  className?: string;
};

/** Cross-fades from skeleton to content with a short blur, so the swap doesn't pop. */
export function SkeletonSwap({ loading, skeleton, children, className }: SkeletonSwapProps) {
  const reduce = useReducedMotion();
  return (
    <div className={cn("grid", className)} aria-busy={loading}>
      <AnimatePresence mode="popLayout">
        <motion.div
          key={loading ? "skeleton" : "content"}
          className="[grid-area:1/1]"
          initial={{ opacity: 0, filter: reduce ? "blur(0px)" : "blur(6px)" }}
          animate={{ opacity: 1, filter: "blur(0px)" }}
          exit={{ opacity: 0, filter: reduce ? "blur(0px)" : "blur(6px)" }}
          transition={{ duration: 0.3 }}
        >
          {loading ? skeleton : children}
        </motion.div>
      </AnimatePresence>
    </div>
  );
}
```