# Skeleton

> Loading placeholders with a scanning shimmer.

Category: Feedback. Docs: https://get-neonkit.vercel.app/components/skeleton

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/skeleton.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/skeleton`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

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

<Skeleton variant="circle" className="size-10" />
<Skeleton variant="text" className="w-40" />
```

## Examples

### Profile card

Mirror the real layout so nothing jumps when the content arrives.

```tsx
import { Skeleton } from "@/components/ui/skeleton";

export default function SkeletonDemo() {
  return (
    <div
      aria-busy="true"
      className="w-full max-w-sm rounded-xl border border-white/10 bg-white/[0.03] p-5"
    >
      <span className="sr-only">Loading profile</span>
      <div className="flex items-center gap-4">
        <Skeleton variant="circle" className="size-12" />
        <div className="grid flex-1 gap-2.5">
          <Skeleton variant="text" className="w-36" />
          <Skeleton variant="text" className="h-2.5 w-24" />
        </div>
      </div>
      <div className="mt-6 grid gap-2.5">
        <Skeleton variant="text" className="h-2.5" />
        <Skeleton variant="text" className="h-2.5 w-11/12" />
        <Skeleton variant="text" className="h-2.5 w-3/5" />
      </div>
      <div className="mt-6 grid grid-cols-3 gap-3 border-t border-white/[0.06] pt-5">
        {["Followers", "Following", "Projects"].map((stat) => (
          <div key={stat} className="grid gap-2">
            <Skeleton className="h-5 w-10" />
            <Skeleton variant="text" className="h-2 w-14" />
          </div>
        ))}
      </div>
      <div className="mt-6 flex gap-2">
        <Skeleton className="h-9 flex-1 rounded-lg" />
        <Skeleton className="size-9 rounded-lg" />
      </div>
    </div>
  );
}
```

### Table rows

Vary the widths. Identical bars look like a wireframe, not data.

```tsx
import { Skeleton } from "@/components/ui/skeleton";

// Varied widths read as real data; identical bars read as a wireframe.
const rows = [
  { name: "w-28", email: "w-36", amount: "w-14" },
  { name: "w-20", email: "w-32", amount: "w-12" },
  { name: "w-32", email: "w-40", amount: "w-16" },
  { name: "w-24", email: "w-28", amount: "w-12" },
  { name: "w-28", email: "w-36", amount: "w-14" },
];

export default function SkeletonTable() {
  return (
    <div
      aria-busy="true"
      className="w-full max-w-lg overflow-hidden rounded-xl border border-white/10 bg-white/[0.03]"
    >
      <span className="sr-only">Loading invoices</span>
      <div className="grid grid-cols-[1fr_auto_auto] gap-6 border-b border-white/[0.06] px-4 py-2.5 text-xs text-white/45">
        <span>Customer</span>
        <span className="w-16">Status</span>
        <span className="w-16 text-right">Amount</span>
      </div>
      <ul>
        {rows.map((row, i) => (
          <li
            key={i}
            className="grid grid-cols-[1fr_auto_auto] items-center gap-6 border-b border-white/[0.04] px-4 py-3 last:border-b-0"
          >
            <div className="flex min-w-0 items-center gap-3">
              <Skeleton variant="circle" className="size-8 shrink-0" />
              <div className="grid min-w-0 gap-2">
                <Skeleton variant="text" className={row.name} />
                <Skeleton variant="text" className={`h-2 ${row.email} max-w-full`} />
              </div>
            </div>
            <Skeleton className="h-5 w-16 rounded-full" />
            <div className="flex w-16 justify-end">
              <Skeleton variant="text" className={row.amount} />
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

### Loading and loaded

Swap the skeleton for content of the same height.

```tsx
"use client";

import * as React from "react";
import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";

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

  React.useEffect(() => {
    if (!loading) return;
    const id = setTimeout(() => setLoading(false), 1800);
    return () => clearTimeout(id);
  }, [loading]);

  return (
    <div className="grid w-full max-w-sm gap-4">
      <article
        aria-busy={loading}
        className="min-h-[13.5rem] rounded-xl border border-white/10 bg-white/[0.03] p-5"
      >
        {loading ? (
          <>
            <span className="sr-only">Loading article</span>
            <Skeleton variant="text" className="h-2.5 w-28" />
            <Skeleton variant="text" className="mt-4 h-4 w-4/5" />
            <div className="mt-4 grid gap-2.5">
              <Skeleton variant="text" className="h-2.5" />
              <Skeleton variant="text" className="h-2.5 w-11/12" />
              <Skeleton variant="text" className="h-2.5 w-1/2" />
            </div>
            <div className="mt-6 flex items-center gap-2.5">
              <Skeleton variant="circle" className="size-7" />
              <Skeleton variant="text" className="h-2.5 w-28" />
            </div>
          </>
        ) : (
          <>
            <p className="text-xs text-white/45">Engineering · 6 min read</p>
            <h3 className="mt-2 text-base font-medium text-white/90">
              How we cut cold starts by 40%
            </h3>
            <p className="mt-1.5 text-sm leading-relaxed text-white/55">
              Moving route handlers to the edge and trimming the server bundle
              took p95 boot time from 820 ms to 490 ms.
            </p>
            <div className="mt-4 flex items-center gap-2.5 text-xs text-white/55">
              <span className="flex size-7 items-center justify-center rounded-full border border-white/10 bg-white/[0.06] text-[10px] font-medium text-white/80">
                MC
              </span>
              Maya Chen · Oct 9
            </div>
          </>
        )}
      </article>
      <Button
        variant="ghost"
        size="sm"
        className="justify-self-center"
        disabled={loading}
        onClick={() => setLoading(true)}
      >
        <RefreshCw /> Reload
      </Button>
    </div>
  );
}
```

## API reference

### Skeleton

Size it with className. Also accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"rect" \| "circle" \| "text"` | `"rect"` | rect is rounded-md, circle keeps a square ratio, text is a 12px pill. |
| `color` | `NeonColor` | `inherited` | Tints the shimmer. Falls back to --neon-accent, then cyan. |

## Accessibility

- Skeletons are aria-hidden. Put aria-busy="true" and a visually hidden "Loading…" on the container they stand in for.
- The shimmer is removed when the user prefers reduced motion; the placeholder shapes stay.

## Source

`components/ui/skeleton.tsx`

```tsx
import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// The band is a fixed 96px wide, so a 40px avatar gets a soft wash and a
// 400px row a narrow sweep. Its layer overhangs 48px on each side, so a
// ±50% translate carries the band fully in and out without clipping it.
const keyframes = `
@keyframes nk-skeleton-shimmer { 0% { transform: translateX(-50%); } 70%, 100% { transform: translateX(50%); } }
`;

const band = [
  "linear-gradient(100deg, transparent calc(50% - 28px), rgb(var(--neon) / 0.08) 50%, transparent calc(50% + 28px))",
  "linear-gradient(100deg, transparent calc(50% - 48px), rgb(255 255 255 / 0.05) 50%, transparent calc(50% + 48px))",
].join(", ");

const variants = {
  rect: "rounded-md",
  circle: "aspect-square rounded-full",
  text: "h-3 rounded-full",
} as const;

export interface SkeletonProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Tints the shimmer. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** `text` is a 12px pill, `circle` keeps a square aspect ratio. */
  variant?: keyof typeof variants;
}

const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
  ({ className, style, color, variant = "rect", children, ...props }, ref) => (
    <div
      ref={ref}
      aria-hidden
      style={neonVars(color, style)}
      className={cn(
        "[--neon:var(--neon-accent,34_211_238)]",
        "relative isolate overflow-hidden bg-white/[0.06]",
        variants[variant],
        className,
      )}
      {...props}
    >
      <style>{keyframes}</style>
      <span
        style={{ backgroundImage: band }}
        className="pointer-events-none absolute inset-y-0 -inset-x-12 -z-10 [animation:nk-skeleton-shimmer_2.4s_ease-in-out_infinite] motion-reduce:hidden"
      />
      {children}
    </div>
  ),
);
Skeleton.displayName = "Skeleton";

export { Skeleton };
```
