# Responsive and input variants

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

Source: https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants/

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:

```tsx title="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:

```tsx title="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:

| 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:

```tsx title="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`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#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](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md#041) 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.

| 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](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#motion) earn their keep: a
`transition` on the same element tweens the properties a rule changes instead of snapping them.

```tsx title="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.

```tsx title="Somewhere your settings UI can reach"
Players.LocalPlayer.SetAttribute("VelaColorScheme", prefersDark ? "dark" : "light");
```

```tsx title="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:

```tsx title="Inline, where a registration would be ceremony"
<frame className="rounded-sm attr-[State=open]:rounded-lg" />
```

```ts title="vela.config.ts — or register a name for it"
plugin(({ addVariant }) => {
  addVariant("open", { attribute: "State", equals: "open" });
});
```

```tsx title="…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:

```tsx
<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.

> **A variant in a plain string literal is enough**
>
> `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)` or `new UDim(a, b)` degrades
  silently in game — see [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md#what-the-runtime-path-still-costs).
  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:

```tsx title="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

- [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) — the other way onto the runtime
  path, and what that path costs you.
- [Utilities reference: Motion](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#motion). Transitions and animations that pair with variants.
- [Layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md) — the utilities you will most often
  want to vary by breakpoint.
- [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) — the full warning code list.
