Files
supabase/packages/config/css/utilities.css
Danny White c8aca8d3a0 chore(design-system): standardise keyboard focus rings (#41575)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

UI / design-system consistency (accessibility).

## What is the current behavior?

Keyboard focus rings are inconsistent across Studio and `packages/ui`:

- Custom Button uses thick `outline` with per-variant colours (brand /
grey / destructive / warning)
- Form controls use muted grey rings (`ring-background-control`)
- Tabs / NavMenu / Radio use soft brand `ring-ring`
- Studio `.inset-focus` uses dark green `outline-brand-600`

Related: [DEPR-354](https://linear.app/supabase/issue/DEPR-354).

## What is the new behavior?

One shared focus recipe, exposed as Tailwind `@utility` classes in
`packages/config/css/utilities.css`:

| Utility | Use when |
| --- | --- |
| `focus-ring` | Buttons, inputs, most controls (offset ring) |
| `focus-inset` | Dense/flush surfaces such as interactive table rows
(renamed from `inset-focus`) |

```txt
# focus-ring
outline-hidden
focus-visible:ring-2
focus-visible:ring-ring
focus-visible:ring-offset-2
focus-visible:ring-offset-background
```

Applied on Button, shadcn form controls, Menu/NavMenu, Command palette
trigger, Studio table rows, and related call sites. Documented in the
design-system accessibility docs. Variants do not change focus ring
colour.

When the ring must appear on a different element than the focused one
(e.g. Menu + ProductMenu `Link` via `group-focus-visible`, or InputGroup
via `:has()`), keep an explicit ring stack. The utilities bake in
`:focus-visible` on the same element.

## Additional context

**Out of scope**

- Full `packages/ui` / Studio / www sweep
- Legacy Studio form-group green box-shadow cleanup
- ESLint rule for bare `outline-none`

## Test plan

Prefer Safari (“hard mode” for `tabIndex`). Expect one soft brand ring
everywhere: not grey, not solid green outline.

### Design system

- [ ]
[Accessibility](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/accessibility):
recipe docs match what you see
- [ ]
[Button](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/button):
Tab primary / default / danger; same ring colour
- [ ] [Table → Row-level
navigation](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/table#row-level-navigation):
Tab an interactive row; inset outline (`focus-inset`) sits inside the
row

### Studio

- [ ] **Org home → table view** (`/organizations/_` or org projects):
switch to the table layout, Tab onto a project row; inset outline sits
inside the row (list/card view uses CardButton, not `focus-inset`)
- [ ] **Project sidebar** (Database, Auth, Storage, …): Tab the main
product nav links; ring follows the focused item (not the nested section
menus like Tables / Roles)
- [ ] **Storage → Files**: Tab a bucket row; same inset outline as org
table rows
- [ ] **Project Settings → General** (or Compute and Disk): Tab through
inputs, checkboxes, switches, selects; same offset ring, no ring on
mouse click
- [ ] **Header ⌘K** (desktop width): Tab to the search control after
Feedback; same soft brand `focus-ring` (was a thicker
`ring-border-strong` before)
- [ ] **Table Editor or SQL Editor tabs**: focus a tab, Tab to × if
active; close shows a ring
- [ ] **Light + dark**: ring stays visible against both backgrounds
2026-07-22 12:10:07 -04:00

209 lines
5.8 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 {
&:focus-visible {
outline-style: solid;
outline-width: 2px;
outline-offset: -2px;
outline-color: var(--ring);
border-radius: var(--radius-md, 0.375rem);
}
}