A variant is a condition evaluated at runtime on the player’s client. A token only applies when its condition matches.
| Variant | Matches when |
|---|---|
sm md lg xl 2xl | viewport width is at least 640, 768, 1024, 1280, 1536 |
max-sm … max-2xl | viewport width is below that same threshold |
attr-[Name=value] | the styled instance carries that Roblox attribute |
portrait | viewport width is less than viewport height |
landscape | viewport width is greater than or equal to viewport height |
touch | the active input mode is touch |
mouse | the active input mode is mouse |
gamepad | the active input mode is gamepad |
hover | the pointer is over this element |
active | this element is being pressed |
focus | this element holds focus or selection |
dark | the local player carries VelaColorScheme = "dark" |
You write a variant as a colon prefix on a utility, exactly as in Tailwind:
<frame className="w-full md:w-1/2 lg:w-1/3" />Prefixes chain, and a chained token requires all of its conditions to match:
<frame className="hidden touch:portrait:visible" />active:, focus: and dark: arrived in 0.7.0. The max- forms and attr-[…] arrived in 0.13.0.
There is still no not- form, no disabled:, and no group or peer variants. Order within the chain
does not matter — the conditions are combined as a set. An unrecognized prefix reports
unknown-variant, whose message lists the supported set. The token is dropped.
Breakpoints, and their complements
Breakpoints are pixel thresholds on the viewport’s X dimension, and since 0.13.0 they are a theme axis rather than a hard-coded three:
| Variant | Applies when | Complement |
|---|---|---|
sm: | width >= 640 | max-sm: — width < 640 |
md: | width >= 768 | max-md: — width < 768 |
lg: | width >= 1024 | max-lg: — width < 1024 |
xl: | width >= 1280 | max-xl: — width < 1280 |
2xl: | width >= 1536 | max-2xl: — width < 1536 |
A max- form is the exact complement of the bare one. The minimum is inclusive and the maximum
is not, so at exactly 768px md: applies and max-md: does not. Between them the two cover every
viewport once. They chain, which is how you address one bucket and nothing else:
<frame className="md:max-lg:w-1/2" />A chain whose bounds leave no viewport, such as lg:max-md: or md:max-md:, reports
invalid-breakpoint-range rather than compiling to a rule that never fires. A max- in front of
something that is not a breakpoint reports unknown-breakpoint and names the ones that are.
Rename or add thresholds through theme.screens. One entry
defines both prefixes. As in Tailwind, write the small-viewport value unprefixed and layer larger
viewports on top.
The helper tracks the camera’s ViewportSize signal, so a breakpoint re-evaluates when the viewport
changes rather than being sampled once at mount. (It was sampled once in 0.4.0, which broke every
breakpoint in that release — see the release notes if
you are pinned to it.)
How orientation is derived
Orientation is not read from a Roblox API. It is computed from the camera’s ViewportSize: if width
is greater than or equal to height the result is landscape, otherwise portrait.
The >= matters. A perfectly square viewport counts as landscape, not portrait. If you are
branching on orientation for a layout that has a meaningful square case, test it explicitly rather
than assuming portrait catches it.
How input mode is detected
The input mode is read from UserInputService and resolved by priority, not by which device was
used most recently:
- If
GamepadEnabledis true →gamepad - Otherwise if
TouchEnabledis true →touch - Otherwise →
mouse
Exactly one mode is active at a time. On a device that reports both a gamepad and a touchscreen,
gamepad: matches and touch: does not — gamepad wins over touch, and touch wins over mouse. A
player who plugs in a controller mid-session flips to gamepad because the helper subscribes to the
TouchEnabled, MouseEnabled, and GamepadEnabled change signals.
Hover, active and focus are per-element
Three variants have conditions that live on the element rather than in the environment. In each case the runtime helper attaches its listeners only when the element carries a rule for that variant. It composes with any handlers you wrote yourself rather than replacing them.
| Variant | Tracked through | Notes |
|---|---|---|
hover | MouseEnter / MouseLeave | |
active | InputBegan / InputEnded | Mouse and touch; also clears on MouseLeave |
focus | Focused / FocusLost on a textbox, SelectionGained / SelectionLost elsewhere |
active: clears on MouseLeave, because a release outside the element never reaches it. Without
that, dragging off a pressed button would leave it stuck.
focus: splits by host because Roblox does: a textbox has real keyboard focus, while every other
element only has gamepad/controller selection. Both map to the same variant.
These are where the motion utilities earn their keep: a
transition on the same element tweens the properties a rule changes instead of snapping them.
<textbutton className="w-28 h-10 rounded-md bg-sky-500 hover:bg-sky-600 active:bg-sky-700 focus:ring-2 transition duration-150" Text="Play"/>Remember that a gamepad or touch player may never hover. Treat hover: styling as feedback, never
as the only signal for something important — focus: is what a controller player actually gets.
Dark mode is state your app owns
Roblox exposes no color scheme to a running game, so dark: cannot read one. It matches when
Players.LocalPlayer carries a VelaColorScheme attribute set to "dark". The runtime host
follows that attribute’s change signal, so flipping it restyles every dark: element immediately.
Players.LocalPlayer.SetAttribute("VelaColorScheme", prefersDark ? "dark" : "light");<frame className="bg-white dark:bg-zinc-900" />Anything other than "dark" is treated as light, including the attribute being absent. The local
player is the one instance every element can reach without a provider, so that is where the flag
lives.
State your UI owns
hover: is a state Roblox exposes. The states a UI has of its own have no fixed list for Vela to
guess at. A panel that is open, a row that is selected, a tier a player reached. You name them.
Both forms read a Roblox attribute off the styled instance:
<frame className="rounded-sm attr-[State=open]:rounded-lg" />plugin(({ addVariant }) => { addVariant("open", { attribute: "State", equals: "open" });});<frame className="rounded-sm open:rounded-lg" />The attribute is the one the rest of the game already reads. It replicates from the server, survives a rejoin, and shows up in Studio’s property panel, so the styling layer holds no second copy of the state. Set it the way you set any other:
<frame ref={(f) => f?.SetAttribute("State", isOpen ? "open" : "closed")} />equals takes a string, number or boolean. Both forms compose with every other variant, and both
are checked, completed, hovered and sorted like a built-in one. Neither sends the utility behind
them to the in-game parser. open:rounded-lg lowers rounded-lg exactly as a bare rounded-lg
does, and only the condition travels. An attr-[…] that does not parse reports
malformed-attribute-variant and names what is missing.
The cost: any variant forces the runtime path
A variant condition cannot be evaluated at compile time, so any token carrying a variant prefix
switches that element onto the runtime path. The JSX tag becomes a VelaRuntimeHost, the
variant tokens are serialized into __velaRules, the original tag travels as __velaTag, and the
static props are emitted as casts.
className="sm:w-full", a fully static string literal, is enough. One variant-prefixed token anywhere in the file pulls the runtime host into that file’s output.
What it costs the file is an import. The host lives in @rbxts/vela-runtime, one ModuleScript the
whole place shares. A file that needs it carries a createVelaRuntimeHost(...) call and the config
it hands over. That is a few hundred bytes. It is a few thousand for a file whose host must parse a
class value and therefore keeps the full theme tables. The host imports
{ Players, TweenService, UserInputService, Workspace } from @rbxts/services, normalizes your
theme’s colour and spacing strings, and installs subscriptions on each element that uses it.
Two consequences:
- Per-element work. Every runtime-path element re-resolves its tokens whenever the environment changes.
- Theme values are re-parsed from text, but only where the host must parse a class value. A value
that is valid roblox-ts but not shaped like
Color3.fromRGB(r, g, b)ornew UDim(a, b)degrades silently in game — see Dynamic class names. A variant alone does not put you here: its rules are resolved by the compiler, so a variant-only file ships its scales emptied.
Output size, diagnostics and coverage are not on that list. Variant-prefixed tokens are analyzed statically, and the runtime path resolves every utility family the static path does.
Use variants where they earn their keep. A handful of top-level containers that reflow between phone and desktop, an input hint that only makes sense on gamepad, hover feedback on interactive controls. Keep leaf components on plain static classes.
Driving reflow yourself
Variants are not the only way to reflow, and sometimes not the best one. When you would rather not pull the runtime host into a file at all, read the viewport yourself and branch between two static class strings. Branching the whole element keeps both looks on the static path, with no host and the full utility set:
function useViewportWide(threshold: number) { const [wide, setWide] = React.useState(false);
React.useEffect(() => { const camera = Workspace.CurrentCamera; if (!camera) return; const update = () => setWide(camera.ViewportSize.X >= threshold); const connection = camera.GetPropertyChangedSignal("ViewportSize").Connect(update); update(); return () => connection.Disconnect(); }, [threshold]);
return wide;}
export function Shell(props: { children?: React.Element }) { const wide = useViewportWide(768);
return wide ? ( <frame className="flex gap-4 p-4 w-full h-full">{props.children}</frame> ) : ( <frame className="flex flex-col gap-2 p-2 w-full h-full">{props.children}</frame> );}See also
- Dynamic class names — the other way onto the runtime path, and what that path costs you.
- Utilities reference: Motion. Transitions and animations that pair with variants.
- Layout and sizing — the utilities you will most often want to vary by breakpoint.
- Diagnostics — the full warning code list.