# Interactions

> Interaction design principles and CSS utilities for building accessible, responsive UI.

Reference: [Emphasis and states tokens](https://ticketsolutionsptyltd.github.io/roadie/tokens/emphasis.md).

Interaction is a conversation. Details are the vocabulary. We build for the
felt experience. We care about what feels right, not just what works.

## Interaction utilities

Roadie provides CSS utilities that encode many of the guidelines on this page
automatically. Apply them to elements that respond to user input.

### `is-interactive`

For clickable elements, such as buttons, cards, links, and toggles. Provides
cursor, transitions, active press, focus ring, and disabled state. Pair with
an `emphasis-*` class for visual styling. Hover, press, and tab through these
buttons to see transitions, active press, and focus rings.

```tsx
{/* Strong */}
<Button emphasis='strong'>Buy tickets</Button>
{/* Normal */}
<Button emphasis='normal'>Buy tickets</Button>
{/* Subtle */}
<Button emphasis='subtle'>Buy tickets</Button>
{/* Subtler */}
<Button emphasis='subtler'>Buy tickets</Button>
{/* Disabled */}
<Button emphasis='strong' disabled>Buy tickets</Button>
```

| Behaviour    | How                                                            |
| ------------ | -------------------------------------------------------------- |
| Cursor       | `cursor: pointer`                                              |
| Transitions  | Colour, shadow, outline, and transform, from the motion tokens |
| Active press | `scale(0.99)`                                                  |
| Focus ring   | `:focus-visible` outline, intent-coloured with transparency    |
| Disabled     | `opacity: 0.5`, `pointer-events: none`, and `grayscale(0.3)`   |

`--interactive-transition` sets each property that animates, with its
duration and its easing token.

| Property           | Duration | Easing          |
| ------------------ | -------- | --------------- |
| `background-color` | 200ms    | `ease-standard` |
| `border-color`     | 200ms    | `ease-standard` |
| `color`            | 200ms    | `ease-standard` |
| `box-shadow`       | 200ms    | `ease-standard` |
| `outline-width`    | 150ms    | `ease-standard` |
| `outline-color`    | 150ms    | `ease-standard` |
| `transform`        | 150ms    | `ease-spring`   |

Roadie has one focus ring shape. It's 4px wide and sits flush against the
element with no gap. The base styles draw it on a focused element without a
Roadie class, such as a link in CMS content or a plain button, and
`is-interactive` and `is-focusable` draw the same ring and animate it in. Its
colour follows the nearest `intent-*` ancestor. Tab through these to see the
ring change.

```tsx
{/* Neutral */}
<Button emphasis='strong' intent='neutral'>Neutral</Button>
{/* Accent */}
<Button emphasis='strong' intent='accent'>Accent</Button>
{/* Danger */}
<Button emphasis='strong' intent='danger'>Danger</Button>
{/* Success */}
<Button emphasis='strong' intent='success'>Success</Button>
{/* Plain link */}
<a href='#is-interactive'>Plain link</a>
```

Fields use the same ring in accent, or danger when invalid.

The ring's width, and its opacity in light and dark mode, come from the focus
ring tokens.

| Sets                  | Token                       | Value |
| --------------------- | --------------------------- | ----- |
| Width                 | `--focus-ring-width`        | 4px   |
| Opacity in light mode | `--focus-ring-opacity`      | 30%   |
| Opacity in dark mode  | `--focus-ring-opacity-dark` | 20%   |

### `is-interactive-within`

For a surface whose main link sits inside it, such as a card that also holds
other links or buttons. Mark that link with `data-interactive-target`. Its
overlay covers the surface, so a click anywhere follows it. Other controls sit
above the overlay and stay clickable. The surface takes the same hover, press,
and focus states as `is-interactive` while its main link is hovered, pressed,
or focused. It does nothing until a target is present. `Card` applies it for
you when it holds a `Card.Link`.

Surfaces can nest. A target belongs to the nearest surface around it, so a
card around a grouped `DataTable` or `RecordTable` list rows stays still while
one of those rows is hovered, pressed, or focused.

```tsx
<Card emphasis='raised'>
  <Card.Content>
    <Card.Title>
      <Card.Link href='#is-interactive-within'>Scans by gate</Card.Link>
    </Card.Title>
    <DataTable
      caption='Scans by gate'
      columns={[
        { key: 'pool', header: 'Pool', kind: 'text' },
        { key: 'gate', header: 'Gate', kind: 'text' },
        { key: 'scans', header: 'Scans', kind: 'number' }
      ]}
      rows={[
        { pool: 'General', gate: 'North', scans: 250 },
        { pool: 'General', gate: 'South', scans: 150 },
        { pool: 'VIP', gate: 'Side', scans: 40 }
      ]}
      groupBy={['pool']}
    />
  </Card.Content>
</Card>
```

```tsx
<Card emphasis='raised'>
  <Card.Content>
    <Card.Title>
      <Card.Link href='#is-interactive-within'>Midnight Frequency</Card.Link>
    </Card.Title>
    <Card.Description>Clockwork Wattle Brewery, Brisbane</Card.Description>
  </Card.Content>
  <Card.Footer>
    <Button emphasis='subtle' size='sm'>Save</Button>
  </Card.Footer>
</Card>
```

### `is-focusable`

For an element that takes focus but isn't clicked, such as a scroll region or
a `tabIndex={-1}` landing target. It gives the same focus ring as
`is-interactive`, from the same tokens, with no pointer cursor, press, or
disabled state. The docs' wide tables use it on their scroll region. Tab into
tonight's program to see the ring.

```tsx
<div
  className='is-focusable emphasis-sunken grid max-h-32 gap-2 overflow-y-auto rounded-xl p-4'
  tabIndex={0}
  role='region'
  aria-label="Tonight's program"
>
  <p className='text-ui'>6:00pm Lampshade Disco</p>
  <p className='text-ui'>7:15pm Feathered Anchor Sessions</p>
  <p className='text-ui'>8:30pm Marzipan Thunderclap</p>
  <p className='text-ui'>9:45pm Tuxedo Possum Cabaret</p>
  <p className='text-ui'>11:00pm Raffia Spaceship</p>
</div>
```

### `is-interactive-field`

For form inputs, such as text fields, textareas, and selects. Provides
state-based colour transitions: neutral at rest, accent on focus, and danger
when invalid. Pair with `emphasis-field` for text fields, or
`emphasis-normal` for select triggers. Focus each input to see the accent
border and ring. The invalid field shows danger styling.

```tsx
{/* Default */}
<Field>
  <Field.Label>Name</Field.Label>
  <Field.Input placeholder='e.g. Sam Okafor' />
</Field>
{/* Invalid */}
<Field invalid>
  <Field.Label>Email</Field.Label>
  <Field.Input defaultValue='bad@' />
  <Field.ErrorText>Enter a valid email address.</Field.ErrorText>
</Field>
{/* Disabled */}
<Field disabled>
  <Field.Label>Promo code</Field.Label>
  <Field.Input placeholder='Cannot edit' />
</Field>
```

| State   | Background                    | Border            | Outline     |
| ------- | ----------------------------- | ----------------- | ----------- |
| Rest    | From the emphasis             | From the emphasis | None        |
| Hover   | neutral-3 (neutral-2 in dark) | neutral-7         | None        |
| Focus   | accent-2 (accent-1 in dark)   | accent-9          | Accent ring |
| Invalid | danger-2                      | danger-9          | Danger ring |

A disabled field takes `opacity: 0.5`, `pointer-events: none`, and
`grayscale(0.3)`.

### `is-interactive-field-group`

For composite form controls where multiple inputs share a single visual
container, like the Combobox input group (a text input and a trigger button).
Provides the same state transitions as `is-interactive-field`, but scoped to
the group wrapper instead of an individual input.

Use it on the wrapping element that contains the child inputs. Focus within
any child triggers the accent transition on the group. Pair with
`emphasis-field`, or `emphasis-normal` for a button-like trigger.

### Selection

`is-unselected` and `is-selected` are for a set of choices, such as a toggle,
a toggle group, or tabs. These utilities set the label colour in every state,
with whichever intent applies, so a component never sets it itself. Apply each
one only in its own state, as in
`not-data-[pressed]:is-unselected data-[pressed]:emphasis-subtle data-[pressed]:is-selected`,
so the resting and chosen utilities never meet. A `subtler` ToggleGroup uses
them in every intent.

```tsx
{/* Neutral */}
<ToggleGroup emphasis='subtler' intent='neutral' aria-label='Range' defaultValue={['week']}>
  <ToggleGroup.Item value='day'>Day</ToggleGroup.Item>
  <ToggleGroup.Item value='week'>Week</ToggleGroup.Item>
  <ToggleGroup.Item value='month'>Month</ToggleGroup.Item>
</ToggleGroup>
{/* Accent */}
<ToggleGroup emphasis='subtler' intent='accent' aria-label='Range' defaultValue={['week']}>
  <ToggleGroup.Item value='day'>Day</ToggleGroup.Item>
  <ToggleGroup.Item value='week'>Week</ToggleGroup.Item>
  <ToggleGroup.Item value='month'>Month</ToggleGroup.Item>
</ToggleGroup>
{/* Danger */}
<ToggleGroup emphasis='subtler' intent='danger' aria-label='Range' defaultValue={['week']}>
  <ToggleGroup.Item value='day'>Day</ToggleGroup.Item>
  <ToggleGroup.Item value='week'>Week</ToggleGroup.Item>
  <ToggleGroup.Item value='month'>Month</ToggleGroup.Item>
</ToggleGroup>
{/* Success */}
<ToggleGroup emphasis='subtler' intent='success' aria-label='Range' defaultValue={['week']}>
  <ToggleGroup.Item value='day'>Day</ToggleGroup.Item>
  <ToggleGroup.Item value='week'>Week</ToggleGroup.Item>
  <ToggleGroup.Item value='month'>Month</ToggleGroup.Item>
</ToggleGroup>
```

| Utility                       | State                           | Label and fill                                                         |
| ----------------------------- | ------------------------------- | ---------------------------------------------------------------------- |
| `is-unselected`               | Rest                            | Subtle text, a step up to normal text on hover                         |
| `emphasis-subtle is-selected` | Chosen                          | Strong text on a soft fill, with a Highlight edge under forced colours |
| `is-selected-label`           | Chosen, over an indicator       | Strong text, while the indicator behind draws the fill                 |
| `is-selected-label-on-strong` | Chosen, over a strong indicator | The label colour of `emphasis-strong`                                  |

A browser test holds the pair in every intent, in light and dark, on the page
and on a card:

- The chosen label reaches APCA Lc 75 on its fill, the minimum for body text.
- It beats a resting label on the same surface by at least Lc 15, so the
  label alone tells them apart.
- A resting label reaches Lc 45, the minimum for non-text UI such as an icon.
- The soft fill sits at least 0.075 OKLCH lightness from the surface. APCA
  reads two soft fills as Lc 0 in dark mode, so lightness measures them
  instead.

The soft fill alone doesn't mark the chosen item clearly enough, so it's for
quiet controls whose icon or label carries the state too. Use
`emphasis-strong` when the state must stand out.

## Emphasis and interaction

Emphasis presets and interaction utilities compose together. The emphasis sets
appearance and the hover and active colour shifts. The interaction utility
adds mechanical behaviours. Together they form a complete interactive element.
Hover each card to compare a static one with an interactive one. A `Card`
with an `href` takes `is-interactive`.

```tsx
{/* Static */}
<Card emphasis='normal'>
  <Card.Content>
    <Card.Title>Ochre Kite Weekender</Card.Title>
  </Card.Content>
</Card>
{/* Interactive */}
<Card emphasis='normal' href='#emphasis-and-interaction'>
  <Card.Content>
    <Card.Title>Ochre Kite Weekender</Card.Title>
  </Card.Content>
</Card>
{/* Raised and static */}
<Card emphasis='raised'>
  <Card.Content>
    <Card.Title>Ochre Kite Weekender</Card.Title>
  </Card.Content>
</Card>
{/* Raised and interactive */}
<Card emphasis='raised' href='#emphasis-and-interaction'>
  <Card.Content>
    <Card.Title>Ochre Kite Weekender</Card.Title>
  </Card.Content>
</Card>
```

`is-interactive-field` provides its own hover, focus, and invalid logic. It
doesn't use emphasis hover states. This is why form inputs use
`emphasis-field`, which has no interactive states, paired with
`is-interactive-field`, which provides all of them.

### Component recipes

The classes the component library applies.

| Component             | Classes                                      |
| --------------------- | -------------------------------------------- |
| Button (strong)       | `btn is-interactive emphasis-strong`         |
| Button (normal)       | `btn is-interactive emphasis-normal`         |
| Button (subtler)      | `btn is-interactive emphasis-subtler`        |
| Text input            | `emphasis-field is-interactive-field`        |
| Select                | `emphasis-normal is-interactive-field`       |
| Select (subtler)      | `emphasis-subtler is-interactive`            |
| Card (raised, `href`) | `rounded-xl emphasis-raised is-interactive`  |
| Link (normal)         | `link-normal`, which includes `is-focusable` |
| Radio (subtler)       | `emphasis-subtler is-interactive`            |

## Guidelines

These guide how we handle interaction across Oztix applications. Follow them
when building custom interactive UI.

- Never hand-roll hover or focus states. Use `is-interactive`,
  `is-interactive-field`, `is-interactive-field-group`,
  `is-interactive-within`, or `is-focusable`, so every control moves and
  focuses the same way.

### Keyboard and focus

#### Keyboard works everywhere

The mouse is optional. Power users and assistive technologies rely on the keyboard.

**Do**

Ensure all flows are keyboard-operable and follow WAI-ARIA Authoring
Practices.

**Don’t**

Build "clickable" `div`s that cannot be focused or activated with Enter
or Space.

#### Clear focus indicators

Navigation requires visibility. Users must always know where they are on the page.

**Do**

Show a visible focus ring for every focusable element. Use
`:focus-visible` to avoid distracting mouse users. Roadie's base styles
draw the ring on plain links and buttons, and `is-interactive` and
`is-focusable` animate it in.

**Don’t**

Remove outlines (`outline: none`) without replacing them with a
high-contrast alternative.

#### Manage focus

Context changes require focus changes. Don't leave the user's focus behind when the UI changes.

**Do**

Use focus traps for modals. Move focus to new content after navigation.
Return focus to the trigger when a menu closes.

**Don’t**

Open a modal but leave the focus on the button that opened it, hidden
under the backdrop.

### Hit targets and touch

#### Match visual and hit targets

Fitts' Law applies to code. What looks clickable must be clickable. It must also be a comfortable size.

**Do**

Expand hit targets to at least 24px on desktop or 44px on mobile, even if
the icon is smaller. Use padding to expand the area.

**Don’t**

Wrap a tiny 12px icon in a click handler with no padding.

#### Mobile input hygiene

Prevent disorienting zooms. Inputs must be legible by default on touch devices.

**Do**

Set `<input>` font size to at least 16px on mobile.

**Don’t**

Allow iOS Safari to auto-zoom or pan when an input is focused because the
text is too small.

#### No dead zones

Proximity implies relationship. There should be no gap between a label and its control.

**Do**

Wrap the input and label in a single clickable container. Clicking a
visible label should focus its control.

**Don’t**

Leave an unclickable gap between a checkbox and its text.

#### Respect user agency

The browser belongs to the user. Never disable native capabilities like zooming or pasting.

**Do**

Allow pasting in all `<input>` and `<textarea>` elements.

**Don’t**

Disable browser zoom (`user-scalable=no`) or block paste events for
"security".

### Forms

#### Labels are mandatory

Every control needs a name, even if visually hidden.

**Do**

Ensure every control has a `<label>` or `aria-label`. Clicking a visible
label should focus its control.

**Don’t**

Rely on placeholder text as the only label.

#### Placeholders are examples

Placeholders should show how to answer, not what to answer.

**Do**

Use "e.g. 0400 000 000" as a placeholder to show the expected format.

**Don’t**

Repeat the label "Phone number" in the placeholder.

#### Enter submits

The Enter key is the universal "Done" signal.

**Do**

Submit the form on Enter when a text input is focused. If multiple
controls exist, apply to the last one.

**Don’t**

Force users to pick up the mouse to click "Save" after typing.

#### Keep submit active

Validation should be educational, not preventative. Disabled buttons hide the "why."

**Do**

Keep the submit button active. Show validation errors on click.

**Don’t**

Disable the submit button while the form is incomplete.

#### Don't block typing

Inputs accept input. Let the user type, then correct them.

**Do**

Allow any characters in a field, then show a validation error if they are
invalid. Trim leading and trailing whitespace before validation.

**Don’t**

Silently block keystrokes, such as preventing non-numbers in a phone
field, without feedback.

#### Inline errors

Contextualise failure. Show the error where the problem is.

**Do**

Display error messages immediately next to the invalid field. Focus the
first error on submit.

**Don’t**

Dump a generic "Form invalid" banner at the top of the page.

#### Autocomplete everywhere

Browsers are smart; let them help. Correct metadata enables one-click filling.

**Do**

Set `autocomplete` attributes and meaningful `name` values on inputs.

**Don’t**

Use generic names like `input_1` that confuse password managers.

#### Unsaved changes protection

Data loss is a critical failure. Warn the user before destroying their work.

**Do**

Trigger a confirmation dialog if the user tries to navigate away with
dirty state.

**Don’t**

Let a stray back-swipe wipe out a 500-word essay.

### Feedback and state

#### Honest loading states

For fast, reversible operations, skip the loader entirely and use optimistic UI instead.

**Do**

For slow or high-stakes operations, show a loader. Commit to it for at
least 300ms. Delay its appearance by 200ms to avoid flash on fast
responses.

**Don’t**

Show a spinner for 50ms then immediately remove it. Flicker creates
anxiety.

#### Optimistic UI

The interface should move as fast as the user's thought. Reserve loading states for high-stakes or slow operations.

**Do**

For fast, reversible actions, such as toggling a like or reordering a
list, update the UI immediately and reconcile with the server in the
background.

**Don’t**

Block the UI with a spinner for every server round-trip, even trivial
ones.

#### Ellipsis implies continuation

An ellipsis indicates that an action is not yet complete or requires more input.

**Do**

Use "Rename…" (opens a dialog) and "Saving…" (process in flight).

**Don’t**

Use "Rename" for a button that opens a modal. The user expects immediate
action.

#### Destructive friction

Regret is expensive. Make irreversible actions hard to do by accident.

**Do**

Require explicit confirmation or provide a generous "Undo" window for
destructive actions.

**Don’t**

Delete a project immediately upon clicking a red trash icon.

#### Forgiving interactions

Users are imprecise. The interface should anticipate intent, not punish inaccuracy.

**Do**

Use prediction cones for menus. Add debounce to tooltips, with a delay
first and instant subsequent ones.

**Don’t**

Close a dropdown the millisecond the mouse leaves the trigger pixel.

## Quick reference

| Element         | Utility                          | Emphasis                           | Notes                                                                                                                                                                           |
| --------------- | -------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Button          | `is-interactive`                 | strong, normal, subtle, or subtler | Add `btn` for the full radius                                                                                                                                                   |
| Text input      | `is-interactive-field`           | field                              | None                                                                                                                                                                            |
| Select          | `is-interactive-field`           | normal                             | None                                                                                                                                                                            |
| Textarea        | `is-interactive-field`           | field                              | None                                                                                                                                                                            |
| Clickable card  | `is-interactive`                 | raised                             | Add `rounded-xl`                                                                                                                                                                |
| Toggle or radio | `is-interactive`                 | subtler or normal                  | None                                                                                                                                                                            |
| Scroll region   | `is-focusable`                   | Any                                | Add `tabIndex={0}`, a `region` role, and a name                                                                                                                                 |
| Link            | `is-focusable`, through `link-*` | None                               | Use [`Link`](https://ticketsolutionsptyltd.github.io/roadie/components/link.md), or `link-normal`, `link-strong`, or `link-subtle`, which bring the focus ring and hover motion |

Docs updated 23 Oct 2026.
