Velaguides

Responsive and input variants

Every variant prefix, how each condition is detected, and what using one costs.

A variant is a condition evaluated at runtime on the player’s client. A token only applies when its condition matches.

VariantMatches when
sm md lg xl 2xlviewport width is at least 640, 768, 1024, 1280, 1536
max-smmax-2xlviewport width is below that same threshold
attr-[Name=value]the styled instance carries that Roblox attribute
portraitviewport width is less than viewport height
landscapeviewport width is greater than or equal to viewport height
touchthe active input mode is touch
mousethe active input mode is mouse
gamepadthe active input mode is gamepad
hoverthe pointer is over this element
activethis element is being pressed
focusthis element holds focus or selection
darkthe local player carries VelaColorScheme = "dark"

You write a variant as a colon prefix on a utility, exactly as in Tailwind:

src/client/Panel.tsx
<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:

Only on a touch device in portrait
<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:

VariantApplies whenComplement
sm:width >= 640max-sm:width < 640
md:width >= 768max-md:width < 768
lg:width >= 1024max-lg:width < 1024
xl:width >= 1280max-xl:width < 1280
2xl:width >= 1536max-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:

Only between 768 and 1024
<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:

  1. If GamepadEnabled is true → gamepad
  2. Otherwise if TouchEnabled is true → touch
  3. 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.

VariantTracked throughNotes
hoverMouseEnter / MouseLeave
activeInputBegan / InputEndedMouse and touch; also clears on MouseLeave
focusFocused / 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.

A button with all three
<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.

Somewhere your settings UI can reach
Players.LocalPlayer.SetAttribute("VelaColorScheme", prefersDark ? "dark" : "light");
And then, anywhere
<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:

Inline, where a registration would be ceremony
<frame className="rounded-sm attr-[State=open]:rounded-lg" />
vela.config.ts — or register a name for it
plugin(({ addVariant }) => {
addVariant("open", { attribute: "State", equals: "open" });
});
…and then
<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.

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) or new 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:

src/client/ResponsiveShell.tsx
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