npx facet-rbxts add scroll-areaCopies ui/scroll-area.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants,
@lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-scroll-area@^0.8.0.
import { ScrollArea } from "../shared/ui/scroll-area";
<frame className="flex-col w-full h-32 rounded-md border border-border"> <ScrollArea> <frame className="flex-col w-full h-fit p-3 gap-2">…</frame> </ScrollArea></frame>import React from "@rbxts/react";import { Label } from "../ui/label";import { ScrollArea } from "../ui/scroll-area";import { Separator } from "../ui/separator";import { MODE } from "../facet-mode";
/** * The height is on the **wrapper**, not on the `ScrollArea`. The root recipe is * `w-full h-full`, so the component fills whatever it is put inside — and a * `className` written here would be resolved at this call site and then * overwritten by that recipe. A scroll area that hugs its content has nothing * to scroll, so something above it has to state the size. */export function ServerList() { const rows: string[] = [ "Hollow Reach — 12/16", "Copper Flats — 9/16", "The Undercroft — 16/16", "Salt Marsh — 4/16", "Ironway Station — 11/16", "Quarry Nine — 2/16", "Long Pier — 15/16", ];
return ( <frame className="flex-col w-full h-32 rounded-md border border-border"> <ScrollArea> <frame className="flex-col w-full h-fit p-3 gap-2"> {rows.map((row, index) => ( <frame className="flex-col w-full h-fit gap-2" key={row}> <Label Text={row} /> {index < rows.size() - 1 ? <Separator /> : undefined} </frame> ))} </frame> </ScrollArea> </frame> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React, toSlotProps } from "@lattice-ui/react-runtime";import { type ScrollAreaOrientation, ScrollArea as ScrollAreaPrimitive } from "@lattice-ui/react-scroll-area";import { type ClassName, cn } from "~/lib/utils";
/** * The root deliberately carries no `flex-*`: the scrollbar is pinned to an edge * with inset classes, and a `UIListLayout` would pull it into the flow. * * **The height comes from the parent.** A scroll area that hugs its content has * nothing to scroll, so something has to state a size — and it cannot be a * `className` at the call site. Vela resolves that class where it is written and * the recipe below, which names `Size` on both axes, is emitted after the props * it arrives as. So `<ScrollArea className="h-32" />` inside a 200px parent * renders 200px tall. Put the height on a frame around it, or edit `root` here. * * The viewport hides Roblox's native scrollbar (`scrollbar-none`) because the * visible one is drawn by `ScrollBar` below: Lattice sizes and positions the * thumb from the scroll ratio and fades it after `scrollHideDelayMs`; this file * only says what it looks like. * * One recipe object rather than four exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const scrollAreaVariants = { root: fv("w-full h-full overflow-hidden"), viewport: fv("size-full scrollbar-none"), // `p-px` is shadcn's. Its inner `border-l-transparent` is a per-side border, // which Vela rejects outright — the padding is what that border was buying. // The bar's own thickness is set at the call site with its position, because // both depend on `orientation`. scrollbar: fv("p-px rounded-full"), thumb: fv("rounded-full bg-border"),};
export type ScrollAreaProps = { /** `auto` shows the bar while scrolling, `always` keeps it, `scroll` matches the platform. */ type?: "auto" | "always" | "scroll"; scrollHideDelayMs?: number; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type ScrollBarProps = { orientation?: ScrollAreaOrientation; className?: ClassName;} & PassthroughProps<Frame>;
const ROOT_OWN_PROPS = ["type", "scrollHideDelayMs", "className", "children"] as const;const BAR_OWN_PROPS = ["orientation", "className", "children"] as const;
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
export function ScrollArea(props: ScrollAreaProps) { return ( <ScrollAreaPrimitive.Root scrollHideDelayMs={props.scrollHideDelayMs} type={props.type}> <frame className={cn(scrollAreaVariants.root({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, ROOT_OWN_PROPS)} > <ScrollAreaPrimitive.Viewport className={cn(scrollAreaVariants.viewport())}> {props.children} </ScrollAreaPrimitive.Viewport> <ScrollBar /> </frame> </ScrollAreaPrimitive.Root> );}
export function ScrollBar(props: ScrollBarProps) { const orientation = props.orientation ?? "vertical";
return ( <ScrollAreaPrimitive.Scrollbar className={scrollAreaVariants.scrollbar({ className: cn( orientation === "vertical" ? "right-0 top-0 h-full w-2.5" : "bottom-0 left-0 w-full h-2.5", props.className, ), })} orientation={orientation} {...toSlotProps(getPassthroughProps<Frame>(props, BAR_OWN_PROPS))} > <ScrollAreaPrimitive.Thumb className={cn(scrollAreaVariants.thumb(), orientation === "vertical" ? "w-full" : "h-full")} orientation={orientation} /> </ScrollAreaPrimitive.Scrollbar> );}The parts
| Part | Renders | Classes |
|---|---|---|
ScrollArea | Frame | w-full h-full overflow-hidden |
ScrollBar | Frame | rounded-full, plus the edge insets for its orientation |
ScrollArea renders a ScrollBar for you. The export exists for a second one on the other axis.
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.
The height comes from the parent
The root is w-full h-full, so the component fills whatever it is put inside. A call-site className is resolved there and arrives as instance properties. The recipe then overwrites every property it also names, and it names Size on both axes.
This is measured: a <ScrollArea className="h-32"> inside a 200px-tall parent renders 200px tall. See overriding from the call site.
So the size goes on a wrapper, along with anything else the recipe states. What does cross from a
call-site class is whatever the recipe leaves alone — rounded-md and border-border reach the
instance fine.
A scroll area that hugs its content has nothing to scroll, so something above it has to resolve a height either way.
Props
ScrollArea
| Prop | Type | Description |
|---|---|---|
| type | "auto" | "always" | "scroll" | auto shows the bar while scrolling, always keeps it, scroll matches the platform. Read by the primitive. |
| scrollHideDelayMs | number | How long the bar lingers after a scroll stops. |
| children | React.ReactNode | What scrolls. Give it h-fit so it can be taller than the viewport. |
| className | ClassName | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see above. |
ScrollBar
| Prop | Type | Description |
|---|---|---|
| orientation | "vertical" | "horizontal" | Which edge it pins to, and which axis the thumb sizes on. Defaults to vertical. |
| className | ClassName | Threaded into the scrollbar recipe's className slot inside the component. |
Two scrollbars, one of them invisible
viewport: fv("size-full scrollbar-none"),The viewport is a Roblox ScrollingFrame, which draws a scrollbar of its own. scrollbar-none
hides it, because the visible one is the ScrollBar beside it — a frame Lattice sizes from the
scroll ratio and fades after scrollHideDelayMs.
Losing that class gives you both bars at once, which is the failure mode to recognise.
The root has no flex-*
The scrollbar is pinned to an edge with inset classes, and a UIListLayout would pull it into the
flow beside the viewport instead.
Slider omits its layout
for the same reason.