Facetcomponents

Skeleton

A placeholder block — and the component defined by what it deliberately does not do.

Terminal window
npx facet-rbxts add skeleton

Copies ui/skeleton.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants and @lattice-ui/react-runtime.

import { Skeleton } from "../shared/ui/skeleton";
// A line of text, as wide as whatever contains it.
<Skeleton />
// Narrower, because the parent is narrower — not because the skeleton was told.
<frame className="w-40 h-fit">
<Skeleton />
</frame>
Three identical <Skeleton />s. The width difference is entirely in their parents.

Renders a Frame. Unknown props forward onto it and are type-checked against it, so a prop Frame does not accept is a compile error.

Props

PropTypeDescription
classNameClassNameThreaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see below, because for this component it is the whole story.

Everything else is forwarded onto the Frame. There is no Text and no children — this is a frame with a colour, like Separator.

You cannot resize it from the call site

Size does not survive as a passthrough prop either — the recipe’s own w-full h-4 resolves after the spread and overwrites it. So there are two things that actually work:

State the width on the parent. w-full means “my parent’s width”, so a skeleton in a w-40 frame is 160px wide. That is what the preview does, and it costs nothing.

Edit the file for anything else. Height, corner radius, a circular avatar placeholder — those live in the recipe, and the recipe is in your project:

src/shared/ui/skeleton.tsx
export const skeletonVariants = fv("w-full h-4 rounded-md bg-muted", {
variants: {
shape: {
line: "",
block: "h-8",
avatar: "size-12 rounded-full",
},
},
defaultVariants: { shape: "line" },
});

That is the model’s answer rather than a workaround: a variant you added is a variant that works, because it is resolved inside the file rather than at the call site.

There is no pulse

shadcn’s skeleton is animate-pulse rounded-md bg-muted. This is the same thing minus the pulse, and that absence is the component’s only real design decision.

If you want the pulse in your project today, the file is yours: TweenService against BackgroundTransparency on a useEffect is about eight lines, and you are then the person who owns those eight lines rather than inheriting them from a registry.

The recipe

export const skeletonVariants = fv("w-full h-4 rounded-md bg-muted");

No variants, no parts. The default size is a line of texth-4 — because the overwhelmingly common use is standing in for one.

Both axes are declared, which is the first convention. w-full defers the width to the parent and h-4 states the height outright, so the component always has a resolved size and never collapses the layout above it — which matters more than usual for a placeholder, since a skeleton exists precisely when there is no content to measure.

The source comment argues that a consumer’s override wins because it lands after the recipe in className. That is true of fv()’s composition and false in practice: the string never arrives. See above.

It keeps its background

const NEUTRAL_PROPS = {
BorderSizePixel: 0,
};

No BackgroundTransparency: 1, for the same reason as Separatorbg-muted is the entire visual, and clearing the background would leave an invisible frame taking up space.