| name | primer-slint-interaction-states |
| description | Designs and implements Primer-style interaction styling in Slint using explicit state dimensions, mutex groups, and `states [ ]` blocks instead of nested ternary expressions. Covers TouchArea order vs FocusScope, `focus-on-click` for `:focus-visible`-style focus rings, hover/pressed/disabled/selected/focus. Use when adding or refactoring Primer components under packages/primer-slint/, complex controls (hover, pressed, disabled, selected, focus), or when the user asks for Checkbox-like state handling or token-driven visuals. |
Primer Slint interaction states
Repo context
Workflow (do this before coding)
- List state dimensions — e.g.
disabled, pointer hover / pressed, selected, checked, focus (if modeled), theme (ColorScheme).
- Mark mutex groups — dimensions that cannot apply at the same time in a way that needs separate styling (e.g.
disabled vs enabled interaction chain; rest vs hover vs pressed only when enabled).
- Draw a small statechart (Mermaid below) so mutually exclusive branches are obvious.
- Assign defaults on the painted element for the “base” case (e.g. rest, enabled, unchecked).
- Add Slint
states [ … ] with named states and property overrides only — avoid repeating full ternary trees on every color: / background: in the tree.
- Compose from tokens — read
CheckboxTokens, ButtonTokens, PrimerColors, LayoutTokens per AGENTS.md and token layers in the readme; do not scatter new hex literals in components.
Mutex groups (conceptual)
Treat disabled as a top-level branch: when disabled is true, hover/pressed styling for pointers must not apply (or is undefined — pick one and keep it consistent).
When enabled, pointer interaction is typically mutually exclusive along one axis: rest → hover → pressed (only one “active” interaction state at a time for a given pointer).
Selection (e.g. list row selected, checkbox checked) is often orthogonal to hover/pressed: you combine “selected” with “hover” for visuals — in Slint, express that either as:
- a single
states list with combined predicates (selected-hover, selected-rest, …), or
- default +
states where the predicate orders from most specific to least (match Checkbox: disabled first, then combined branches).
Order Slint state branches from most specific to least where the language requires it; put disabled early so it wins over hover.
Statecharts (Mermaid)
1) Top-level: disabled vs enabled
stateDiagram-v2
[*] --> Enabled
[*] --> Disabled
Disabled --> Enabled : disabled becomes false
Enabled --> Disabled : disabled becomes true
Disabled and enabled interaction are mutually exclusive for pointer styling.
2) Enabled pointer: rest, hover, pressed
stateDiagram-v2
[*] --> Rest
Rest --> Hover : has-hover
Hover --> Rest : not has-hover
Hover --> Pressed : pressed
Rest --> Pressed : pressed
Pressed --> Hover : pressed and has-hover
Pressed --> Rest : not pressed
3) Example: list row — selection × interaction (design aid)
Selection does not replace the need for hover feedback; combine them in named states entries (e.g. selected-hover, selected-rest, unselected-hover) instead of duplicating long ternary chains.
flowchart TB
subgraph disabledBranch [Disabled]
D[row disabled styling]
end
subgraph enabledBranch [Enabled]
S[selected?]
S -->|yes| SH[selected + hover or rest or pressed]
S -->|no| UH[unselected + hover or rest or pressed]
end
Slint pattern (sketch)
rectangle := Rectangle {
border-color: /* rest default */;
background: /* rest default */;
states [
disabled when root.disabled: { /* … */ }
selected-pressed when !root.disabled && root.selected && ta.pressed: { /* … */ }
selected-hover when !root.disabled && root.selected && !ta.pressed && ta.has-hover: { /* … */ }
selected-rest when !root.disabled && root.selected && !ta.pressed && !ta.has-hover: { /* … */ }
/* … unselected variants … */
]
}
Use a TouchArea (ta) for has-hover / pressed when applicable.
FocusScope + TouchArea + keyboard ring (:focus-visible parity)
Use this whenever a control must support both pointer activation and Tab/keyboard activation and a visible focus ring only for keyboard focus (browser :focus-visible behavior).
Required structure (ordering)
Always nest: FocusScope → TouchArea → content (row, label, painted Rectangle).
focus-on-click: false
On the FocusScope, set focus-on-click: false when the focus ring is driven by FocusScope.has-focus (or equivalent).
- Pointer / touch: does not move keyboard focus into the
FocusScope, so has-focus stays false — no focus ring on tap/click.
- Activation still happens via
TouchArea.clicked (first interaction works).
- Tab: focuses the
FocusScope → has-focus true → show the ring (e.g. Rectangle border around the control, TextInputTokens.borderColor-focus / PrimerColors.fgColor-link per component).
Space / Enter: handle in FocusScope.key-pressed when the scope has focus.
enabled on FocusScope
When the control is disabled or non-interactive, set FocusScope.enabled (and TouchArea.enabled) so the control does not stay in the tab order incorrectly.
Sketch
fs := FocusScope {
enabled: !root.disabled && root.interactive;
focus-on-click: false;
key-pressed(event) => {
/* Space / Return → activate */
}
ta := TouchArea {
enabled: !root.disabled && root.interactive;
clicked => {
root.activated();
}
/* layout + visuals */
}
}
Anti-patterns (focus)
TouchArea outside FocusScope — double-click / second tap to activate; broken pointer parity.
focus-on-click: true (default) + ring from has-focus — ring appears on every mouse click (not :focus-visible-aligned); use focus-on-click: false for ring-from-keyboard-only when combined with TouchArea activation.
Anti-patterns
- Deep nested ternaries on every property for every combination of flags — hard to read and easy to get wrong when adding a dimension.
- Mixing token logic with layout — keep dimensions in
states or small property <bool> helpers, not inline in unrelated components.
Related skills
Verification
From monorepo root: pnpm autofix and ensure app/src/ui/main.slint loads (see AGENTS.md).