Surface aware outline focus indicator; rethink field outlines - #92
Merged
Conversation
One focus indicator family-wide: a 2px outline at 2px offset (the surface shows through the gap), coloured by a new focusRing semantic token — ink by default, white inside an element tagged with the exported darkSurface constant (data-surface="dark", the onDark condition). Custom properties inherit, so tagging a bar covers every control in it, and portalled overlays escape the tag with the DOM. Reviewed against react-spectrum S2 throughout; the shape is theirs (outline longhands, offset gap, keyboard-only modality) with the tag replacing their lch colour math. Replaces the per-surface ring choice: shadows.outline/outlineDark/ outlineLight and Chakra's blue glow are gone (outlineDark meant two different things across the family), along with the toolbar variant's focusVisible fork and the transparent-outline forced-colors hack — the ring is a real outline, which forced-colors modes recolour natively. Ring layers never animate (box-shadow removed from transition lists): a focus indicator must appear instantly. Modality: the ring is keyboard-only on text inputs (react-aria's isTextInput handling; RAC's data attributes, deliberately not native :focus-visible, which fires on any focus for text inputs). The widened focusVisible condition now gates native :focus-visible with :not([data-rac]) — react-aria tracks modality more strictly than the browser, fixing a ring on mouse-only focus restore from menus. NativeSelect (no RAC attributes) opts back in via native :focus-visible. Fields: borders go 2px at rest (S2's move — focus changes only colour, so the stroke carries its weight; also matches Checkbox/Radio), any focus takes them to a resurrected focusBorder token (brand.600, white under the tag) so the focused state keeps brand interest for everyone, and the keyboard ring stacks outside. The old border+spread-shadow treatment seamed at the corners; invalid states are border-colour-only now too (the error message is the WCAG 1.4.1 cue, as S2). Menu and Select options keep their background highlight but gain the ring for keyboard navigation (gray.100 is ~1.07:1 — no indicator), inset via a new outlineInset utility value because full-bleed rows would overhang the popover. Breaking for the app sweep (family lockstep): outlineDark/outlineLight deleted, focusShadow values changed, field borders 2px, focusBorder re-graded to brand.600 (needs 3:1 per brand), dark bars must tag. The Button Variants story (light + tagged-dark strips) is the acceptance harness.
danger.500 (Chakra red.500, #e53e3e) is 4.13:1 on white — border-grade, not the 4.5:1 small text needs. 600 (#c53030) is 5.47:1; the Button warning variant already made the same move. The invalid border stays 500 (borders need 3:1). The ramp itself is Chakra-inherited and ungraded against the family contract — parked in ui-private's docs/brand-review.md.
It has emitted no shadow since the rethink; the name was kept to dodge @pandacss/preset-base's focusRing utility, but empirically Panda's extend-merge replaces the transform wholesale (the emitted CSS is ours alone, verified) while unioning the values arrays — so preset-base's outside/inside/mixed/none still typecheck and fall through to the standard ring. Documented at the utility; the class becomes focus-ring_*. Utility and colour token now share the focusRing name, matching S2's vocabulary. Rename lands before any release, so the app sweep only ever sees the new name.
focusVisibleRing and the focusRing* companion utilities are stuck in the API surface (extend-merge can't remove them) with a #005FCC default. They stay off-limits — their selector lacks the :not([data-rac]) gating and the alias resolves on <html>, so no dark-surface awareness — but a stray use now renders in the family ink rather than blue.
Deploying ui with
|
| Latest commit: |
f28f1e3
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f1484e7f.ui-2wg.pages.dev |
| Branch Preview URL: | https://focus-rethink.ui-2wg.pages.dev |
The gray.400 rest came from the common over-reading of WCAG 1.4.11 — 3:1 applies to visual information required to IDENTIFY a control, and a labelled field is identified by its label, so the resting boundary is decorative and may be light (as S2/Radix/Apple rest). At the 2px weight the dark rest also read heavy. Hover now steps gray.300 -> 500 (a bigger, clearer change); focus/invalid unchanged and all interactive states clear 3:1. Conditions documented at the recipe and in ui-private's docs/a11y-positions.md: every field must be labelled, and Checkbox/Radio keep gray.400 — their box IS the identifier. The ramp comment now scopes 400's boundary duty accordingly.
The stepper column and its fills' corner radii were built against the old 1px field border (margin 1px, height -2px, radius -1px), so the hover/pressed fills painted over the inner half of the new 2px border and squared off against its corner arcs. All three insets follow the border width now.
ComboBox was the odd one out: its ring showed on mouse focus, because the input's RAC attributes are stripped by the synthetic blur during virtual focus, and native :focus-visible fires on any focus for text inputs. The fix combines the two signals that survive that blur: the component renders a data-keyboard-modality attribute from react-aria's global modality tracker (useFocusVisible with the text-input key filter), and the ring rule gates its :has(input:focus) arm on it. Click: caret + brand border, no ring — as TextField. Arrows with the list open: the active option carries the keyboard indicator; the trigger rings again on Escape/Tab. Verified in-browser; the existing ComboBox continuity tests pass unchanged (jsdom's programmatic focus registers as virtual modality, which counts as focus-visible). react-aria joins the peer dependencies — it is already every consumer's transitive dependency via react-aria-components at the same version.
The stepper column overlays the input as a sibling, so pointer-over- stepper never fired the input's own :hover and the border stayed at rest. The group now carries the hover tint (same gray.500 as the input recipe). First attempt relied on specificity to keep focused/invalid winning — wrong: slot recipes emit into @layer recipes.slots, ordered after recipes, so the group rule beat the input recipe's focused and invalid borders outright. The rule now excludes those states in a :not() list, and the trap is recorded in docs/hints.md.
The focused-item background faded in at ultra-fast while the inset ring snapped, so keyboard navigation moved a lagging highlight under an instant ring. The highlight moves with focus, which makes it focus indication — and focus indication never animates. ListBox/GridList keep their background transitions: theirs are hover/selected states, not the moving keyboard indicator.
The Input component renders a native input, so it never gets RAC's data-focus-visible and the recipe's ring rule could never match it — ml-trainer's project search had no keyboard focus ring at all. Track modality in the component (useFocusRing with the text-input filter, the ComboBox pattern) and render the attribute ourselves. Verified: Tab rings, click doesn't, matching TextField exactly.
'Every field labelled' was too narrow — visual identification is the condition, and an unlabelled search box's >=3:1 icon/placeholder carries it (a11y-positions.md refined to match).
microbit-matt-hillsdon
force-pushed
the
focus-rethink
branch
from
August 14, 2026 21:34
2af682b to
32a1b11
Compare
The focus work accreted more rationale than anyone wants to read at call sites. Comments now state the constraint and point at ui-private's docs (a11y-positions, brand-review) or docs/hints.md for the reasoning; stale claims fixed along the way (the input header still described any-focus rings, Button's transition note still described the deleted shadow layer).
Toast ships the only dark surface in the package and never tagged it: the close button's ink ring sat on teal at 1.87:1, against the 3:1 WCAG 2.4.11 wants. The tag goes on the button rather than the card, because the flip covers the tagged element too and the card is focusable itself — its own ring is drawn outside it, on the page, where ink is right. That ring is new: the card fell back to the UA default until now. The input recipe's ring fired only on [data-focus-visible], which only RAC and our own Input set, so a bare element wearing the recipe kept the border tint and lost the ring — NativeSelect had to patch it back per call site. Native :focus-visible is the fallback now, minus input/textarea, where browsers match it on a pointer click and would undo the modality tracking ca11ec6 added. NativeSelect's patch goes. zIndex moves back to any-modality focus: it had followed the ring to keyboard-only, so a pointer-focused field's 2px border was no longer lifted over its attached neighbour's. focusRing: "none" typechecks (the values arrays union with preset-base's) and painted a permanent un-gated ring, the opposite of the call site's intent. preset-base's surviving focusRingWidth/Offset/ Style wrote --focus-ring-* properties our transform never reads; they point at the outline longhands now. NumberField's :user-invalid takes its own :not(), which is not forgiving: one list would drop the hover rule outright on a browser that doesn't know the pseudo-class (Safari < 16.5). react-aria became a peer in a20f996 but no workspace declared it, so it resolved only through react-aria-components' hoist. Docs: focusRing/focusBorder overrides must keep their { base, _onDark } shape or silently lose the flip; the tag covers the element it sits on, not just its descendants; and an app adopting the toolbar button variant needs darkSurface in the same change.
microbit-matt-hillsdon
force-pushed
the
focus-rethink
branch
from
August 14, 2026 22:32
32a1b11 to
f28f1e3
Compare
microbit-matt-hillsdon
added a commit
that referenced
this pull request
Aug 14, 2026
Button's Variants story gained a tagged-dark strip in #92; IconButton's and LinkButton's stayed on white, where ghost, plain and toolbar are pixel-identical — all three are transparent-or-white with dark text at rest, and toolbar's whiteAlpha hover is invisible on a white page. Nothing in either story told them apart. The dark strip separates them and shows why: ghost is black on black there, plain takes the page's text colour unless the bar sets one, and toolbar is the on-dark variant. The note goes on Button's story, which the other two point at. IconButton's labels take their colour from a ternary of literals rather than a prop forwarded through the row component — the first attempt rendered them in the inherited colour with no CSS emitted at all (docs/hints.md).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One focus system for the app family, replacing the per-surface ring choice
(
outline/outlineDark/outlineLight) that had drifted into threemeanings across the apps and was in some cases (nextgen data particularly,
ill suited for its surface). This kills the last of Chakra UI's blue which was
varyingly inappropriate across the apps.
The ring
Colour comes from the
focusRingsemantic token: ink by default, whiteinside an element tagged
data-surface="dark"(spread the exporteddarkSurfaceconstant on a dark-by-design bar; the inherited var coverseverything in it, and portalled overlays escape with the DOM). A real
outline, so forced-colors modes recolour it natively — the old
transparent-outline hack is gone.
focusShadow→focusRing(it emits no shadow),with an
outlineInsetvalue for full-bleed rows in popovers. It shadowsPanda preset-base's utility of the same name; the leaked foreign values
are documented and inert.
lists; menu/option highlights snap with the ring.
Modality
than the native heuristic (which fires on any focus for text inputs).
Native
:focus-visiblecounts only on non-RAC elements(
:not([data-rac])) — fixes rings appearing on mouse-only menudismissal. ComboBox and the bare
Inputcomponent render the modalitystate themselves where RAC's per-element attributes can't help.
Fields
light
gray.300— valid while something other than the boundaryidentifies the field (label, or a ≥3:1 icon/placeholder); checkbox
boundaries keep
gray.400. Any focus takes the border tofocusBorder(now
brand.600, white under the tag); the keyboard ring composes ontop. Invalid is border-colour-only, with the error message as the
non-colour cue. Error text and the required asterisk move to
danger.600(red.500 is border-grade, 4.13:1). NumberField's stepperinsets and hover follow the new border. The 1px versions looked poor
in the red/focused status (especially at the corners) and this seems
to be the normal weight.
Docs
lives in ui-private:
docs/a11y-positions.md(auditor-facing positions,very much a WIP and will need to discuss brand colours).
Breaking (see
focus-rethinkbranch on all four apps)shadows.outline*deleted;focusShadowrenamed with new values; fieldborders 2px and lighter at rest;
focusBorderre-graded tobrand.600(each brand's 600 needs 3:1); dark bars must carry
darkSurface.react-ariajoins the peer dependencies