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 w-full h-4 resolves after the spread. Two things 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 works, because it resolves 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 today, the file is yours. TweenService against BackgroundTransparency in a useEffect is about eight lines, and you own them rather than inheriting them.

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 text, h-4, because the overwhelmingly common use is standing in for one.

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

The source comment argues a consumer’s override wins because it lands after the recipe in className. True of fv()’s composition, false in practice — the string never arrives.

It keeps its background

const NEUTRAL_PROPS = {
BorderSizePixel: 0,
};

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