Files
Danny White 9f5b5ea6a7 fix(ui): standardise disclosure focus rings (#49660)
## What kind of change does this PR introduce?

Bug fix and design-system accessibility update.

## What is the current behavior?

Accordion and collapsible triggers do not consistently use the shared
focus treatment. Accordion focus can animate from the browser outline,
while call sites need to manage radius and focus geometry themselves.

## What is the new behavior?

Bare accordion and collapsible triggers use the rounded `focus-inset`
treatment by default, positioned 2px inside the control. Interactive
children composed with `asChild`, such as a standard `Button`, retain
their own focus treatment. Accordion triggers include a small default
horizontal hit-area padding (`px-2`) and retain an explicit outer-ring
option for standalone controls. The focus indicator is not animated.

Focused tests cover the default and opt-out accordion variants.

| Before | After |
| --- | --- |
| <img width="1792" height="384" alt="CleanShot 2026-09-07 at 15 40
23@2x"
src="https://github.com/user-attachments/assets/8c8fd99a-3669-465a-a7b2-d272ec92dec7"
/> | <img width="1784" height="498" alt="CleanShot 2026-09-07 at 15 39
52@2x"
src="https://github.com/user-attachments/assets/7a0b7de1-a4bc-48cf-b9fc-3421618806b9"
/> |
| <img width="1564" height="360" alt="CleanShot 2026-09-07 at 15 35
11@2x"
src="https://github.com/user-attachments/assets/34945180-e671-4dc2-981a-4c683d08f221"
/> | <img width="1552" height="378" alt="CleanShot 2026-09-07 at 15 36
19@2x"
src="https://github.com/user-attachments/assets/09f2c030-9ffb-4fa5-bf63-5e172326e8ef"
/> |

## Visual impact

Most call sites should look the same at rest. The exceptions are
accordion triggers that did not already set their own horizontal
padding: they pick up the new default `px-2`, `rounded-md`, and lose the
old blanket `transition-all` on the trigger (chevron rotation is
unchanged).

| Resting layout change | Why |
| --- | --- |
| Accordion triggers without an explicit `px-*` | Inherit new default
`px-2` and `rounded-md` |
| Accordion triggers with their own `px-*` | Unchanged (call-site
padding wins) |
| Collapsible triggers | Focus ring only; spacing comes from the child
element |
| Direct `focus-inset` users (table rows, etc.) | Focus ring only |

## To test

Use Tab to focus each disclosure trigger. Confirm the focus outline is
rounded, sits 2px inside the trigger, and appears immediately without a
dark flash.

Previews:
[design-system](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)
·
[studio-staging](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)
·
[docs](https://docs-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)
·
[www](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app)

### Design system

-
[Accordion](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/accordion)
-
[Collapsible](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/collapsible)

### Studio (focus-only unless noted)

- [Account → Access tokens → **New
token**](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/account/tokens):
permission category accordions (already `px-4`)
- [Database →
Roles](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/database/roles):
role row collapsibles
- [Observability → Query
performance](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/observability/query-performance):
index suggestion accordions (already `px-4`)
- [Settings → JWT
(legacy)](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/settings/jwt/legacy):
migration help collapsible

### Docs

- Docs mobile nav (narrow viewport): accordion sections **(resting
layout: +px-2)**

### WWW

- [Pricing →
FAQs](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/pricing):
FAQ accordions **(resting layout: +px-2)**
- WWW mobile nav (narrow viewport): menu accordions **(resting layout:
+px-2)**

### Not worth opening manually

Sheets, modals, error fallbacks, and replication/cron/integration
advanced-settings accordions inherit the focus fix but need specific
flows or failure states to reach. Error-state troubleshooting accordions
(`TroubleshootingSections`) also gain **+px-2** when visible.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## New Features
- Accordion triggers now use an inset focus style by default, with an
option to use the standard ring style.
- Accordion and collapsible trigger styling can be customized more
flexibly.

## Accessibility
- Focus indicators appear consistently without flashing browser
defaults.
- Focus transitions are limited to visual properties that preserve
immediate keyboard focus visibility.

## Documentation
- Updated accessibility guidance explains inset focus behavior and
transition considerations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-07 16:59:40 +10:00

214 lines
6.1 KiB
CSS

/*
* Custom Tailwind v4 utilities migrated from packages/config/ui.config.js
* (addUtilities + matchUtilities) and packages/config/tailwind.config.js
* (the .bg legacy shim).
*/
/*
* The `bg` class worked in Tailwind v3 but stopped working in v4. Restored as
* an alias for `bg-default` to avoid a wide rename across the codebase.
* Should be deprecated in favor of `bg-default`.
*/
@utility bg {
background-color: var(--background);
}
/*
* Soft shimmer gradient used by LoadingLine.
*/
@utility line-loading-bg {
background: rgb(0, 0, 0);
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(255, 255, 255, 0.65) 50%,
rgba(0, 0, 0, 0) 100%
);
}
@utility line-loading-bg-light {
background: rgb(0, 0, 0);
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(33, 33, 33, 0.65) 50%,
rgba(0, 0, 0, 0) 100%
);
}
/* may be unused — verify before pruning */
@utility mask-fadeout-right {
-webkit-mask-image: linear-gradient(to right, white 98%, transparent 100%);
mask-image: linear-gradient(to right, white 98%, transparent 100%);
}
/* may be unused — verify before pruning */
@utility mask-fadeout-left {
-webkit-mask-image: linear-gradient(to left, white 98%, transparent 100%);
mask-image: linear-gradient(to left, white 98%, transparent 100%);
}
/*
* Functional color utilities. The `--value(--color-*)` token accepts any
* registered --color-* design token (e.g. highlight-foreground-light).
*/
/* may be unused — verify before pruning */
@utility highlight-* {
box-shadow: inset 0 1px 0 0 --value(--color-*);
}
/* may be unused — verify before pruning */
@utility subhighlight-* {
box-shadow: inset 0 -1px 0 0 --value(--color-*);
}
/*
* Inline-SVG grid background. Used by ProjectLinker as bg-grid-black/5 and
* bg-grid-white/5 for the integrations onboarding visuals.
*/
@utility bg-grid-* {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='--value(--color-*)'><path d='M0 .5H31.5V32'/></svg>");
}
@utility no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
}
/*
* Text shimmer (shadcn shimmer utility, inlined so we don't pull in the full
* shadcn CSS package). Apply `shimmer` to text to draw attention.
*/
@property --shimmer-angle {
syntax: '<angle>';
inherits: true;
initial-value: 20deg;
}
@property --shimmer-image {
syntax: '*';
inherits: false;
}
@property --shimmer-text-fill {
syntax: '*';
inherits: false;
}
@utility shimmer {
--_spread: var(--shimmer-spread, calc(3ch + 40px));
--_base: currentColor;
--_highlight: var(--shimmer-color, oklch(from currentColor l c h / calc(alpha* 0.2)));
background-image: var(
--shimmer-image,
linear-gradient(
calc(90deg + var(--shimmer-angle)),
var(--_base) calc(50% - var(--_spread)),
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% - var(--_spread) * 0.5),
var(--_highlight) 50%,
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% + var(--_spread) * 0.5),
var(--_base) calc(50% + var(--_spread))
)
);
background-repeat: no-repeat;
background-size: calc(200% + var(--_spread) * 2) 100%;
background-position: 0 0;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: var(--shimmer-text-fill, transparent);
animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;
transition: -webkit-text-fill-color 0.5s ease;
/* Fade to solid text on dismiss — keep the gradient until fill is opaque */
&[data-shimmer-fading] {
-webkit-text-fill-color: currentColor;
animation-play-state: paused;
}
@variant dark {
--_highlight: var(
--shimmer-color,
oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4))
);
}
&:where([dir='rtl'], [dir='rtl'] *) {
animation-direction: reverse;
}
}
@utility shimmer-once {
animation-iteration-count: 1;
}
@utility shimmer-reverse {
animation-direction: reverse;
}
@utility shimmer-none {
--shimmer-image: none;
--shimmer-text-fill: currentColor;
background-image: none;
-webkit-text-fill-color: currentColor;
}
@utility shimmer-color-* {
--shimmer-color: --value(--color-*, [color]);
--shimmer-color: color-mix(
in oklch,
--value(--color-*, [color]) calc(--modifier(integer) * 1%),
transparent
);
}
@utility shimmer-duration-* {
--shimmer-duration: calc(--value(integer) * 1ms);
}
@utility shimmer-spread-* {
--shimmer-spread: calc(var(--spacing) * --value(integer));
--shimmer-spread: --value([length], [percentage]);
}
@utility shimmer-angle-* {
--shimmer-angle: calc(--value(integer) * 1deg);
}
@media (prefers-reduced-motion: reduce) {
.shimmer {
animation: none;
background-image: none;
-webkit-text-fill-color: currentColor;
}
}
/*
* Canonical keyboard focus rings. Prefer these over inventing local ring/outline
* stacks.
*
* - focus-ring: default offset ring (buttons, inputs, most controls).
* `outline-hidden` stays always-on so mouse click does not show the browser
* default; the ring is `:focus-visible` only.
* - focus-inset: dense/flush surfaces such as interactive <tr>s. Must use
* outline (not ring/box-shadow) — box-shadow on table-row is unreliable,
* especially in Safari. Do NOT pair with outline-hidden: that sets
* outline-style: none and suppresses the focus outline.
*/
@utility focus-ring {
@apply outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
@utility focus-inset {
/* Reserve outline geometry up front so focus never flashes the browser default. */
outline: 2px solid transparent;
outline-offset: -2px;
/* Call sites often add `transition`/`transition-all`; keep outline instant. */
transition-property:
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, transform,
filter, backdrop-filter, box-shadow;
&:focus-visible {
outline-color: var(--ring);
border-radius: var(--radius-md, 0.375rem);
}
}