npx facet-rbxts add skeletonCopies 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>import React from "@rbxts/react";import { Skeleton } from "../ui/skeleton";import { MODE } from "../facet-mode";
/** * Three identical `<Skeleton />`s in parents of different widths. * * Deliberately not `<Skeleton className="w-40" />` — a `className` written at a * Vela-compiled call site never reaches the component, so all three would come * out the same width and the preview would be a lie. `w-full` means "my * parent's width", so the parent is where the width is stated. */export function Skeletons() { return ( <frame className="flex-col gap-2 w-full h-fit"> <frame className="w-full h-fit"> <Skeleton /> </frame> <frame className="w-40 h-fit"> <Skeleton /> </frame> <frame className="w-24 h-fit"> <Skeleton /> </frame> </frame> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React } from "@lattice-ui/react-runtime";import { type ClassName, cn } from "~/lib/utils";
/** * A placeholder for content that has not arrived. * * shadcn's is `animate-pulse rounded-md bg-accent`, and all three carry over. * `animate-pulse` is Vela's own loop, not a `transition-*` — it compiles the * element into Vela's runtime host and drives the tween there, so the pulse * costs this file nothing but the token. * * The surface is `bg-accent`, not `bg-muted`. They are close in the zinc ramp * and they are not the same role, and this is the component shadcn moved. * * The default size is a line of text. Both axes are declared because everything * here declares both axes, and a consumer overriding one of them lands after * this in `className`, so `<Skeleton className="h-32 w-32" />` wins. */export const skeletonVariants = fv("animate-pulse w-full h-4 rounded-md bg-accent");
export type SkeletonProps = { className?: ClassName } & PassthroughProps<Frame>;
const OWN_PROPS = ["className"] as const;
// No `BackgroundTransparency: 1`: like `separator`, the background is the// component.const NEUTRAL_PROPS = { BorderSizePixel: 0,};
export function Skeleton(props: SkeletonProps) { return ( <frame className={cn(skeletonVariants({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, OWN_PROPS)} /> );}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
| Prop | Type | Description |
|---|---|---|
| className | ClassName | Threaded 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
Measured in the preview above, not predicted. A className at a Vela-compiled call site is consumed there and never reaches the component, so three skeletons with three different classes come out identical. No diagnostic on either side.
It bites harder here than anywhere else, because className is the only prop Skeleton has. A component with variant and size still has an API when className is inert. This one does not.
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:
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.
The families it has are transition-*, duration-* and ease-*. Those animate a change, a property moving between resolved values. A pulse is a loop with no triggering change, so nothing in the class vocabulary expresses it.
Driving it from the component means a useEffect and a tween in a file that is otherwise a recipe and a frame. That is runtime behaviour nobody can check by reading the source. It stays out until Facet has an animation primitive worth putting under it.
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.