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 the case where you want 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: the component fills whatever it is put inside. A className written at
the call site is resolved there and arrives as instance properties, which the recipe then
overwrites for 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.
The mechanism is in
overriding from the call site.
So the size goes on a wrapper, and so does 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, because the root recipe names neither a corner nor a stroke.
A scroll area that hugs its content has nothing to scroll, so something above it has to resolve a height either way. Putting it on a wrapper frame is the one arrangement that always works.
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 and positions 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 — right-0 top-0 h-full w-2 for a vertical
one — and a UIListLayout would pull it into the flow and lay it out beside the viewport instead.
Slider omits its layout for
the same reason.