diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx
index a549d1eb7b6..0d71788abac 100644
--- a/apps/design-system/content/docs/accessibility.mdx
+++ b/apps/design-system/content/docs/accessibility.mdx
@@ -25,14 +25,68 @@ All interactive page elements should be reachable by keyboard. Given the below i
Chromium-based browsers and Firefox handle this automatically via the Tab key. Safari, by default, requires the Option key to also be held down. Enabling _Keyboard navigation_ on macOS Settings [removes this requirement](https://mayank.co/blog/safari-focus/#keyboard-navigation) but makes links non-tabbable as a result.
-Interactive page elements should also provide visual feedback upon selection via a `focus-visible` state. We use consistent focus styles such as `inset-focus` so users recognize this state instantly.
+Interactive page elements should also provide visual feedback upon selection via a `focus-visible` state. We use one shared focus ring so users recognize this state instantly.
-[Button](components/button) has all of the above built-in. The same explicit `tabIndex` default is also baked into Checkbox, Switch, Select Trigger, Toggle, Accordion Trigger, Collapsible Trigger, Dropdown Menu Trigger, Popover Trigger, Dialog Trigger, Sheet Trigger, Alert Dialog Trigger, and the Sidebar Menu and action buttons. Bespoke interactive elements however, such as the below interactive [Table Row](components/table#examples), require these props to be added manually:
+### Focus ring recipe
+
+Prefer the shared utilities over inventing local styles:
+
+| Utility | Use when |
+| ------------- | -------------------------------------------------------------------------- |
+| `focus-ring` | Buttons, inputs, and most controls (offset **ring**) |
+| `focus-inset` | Dense or flush surfaces such as interactive table rows (inset **outline**) |
+
+```tsx
+className = 'focus-ring'
+// or
+className = 'relative cursor-pointer focus-inset'
+```
+
+These expand to:
+
+**`focus-ring`**
+
+```txt
+outline-hidden
+focus-visible:ring-2
+focus-visible:ring-ring
+focus-visible:ring-offset-2
+focus-visible:ring-offset-background
+```
+
+**`focus-inset`**
+
+Uses `outline` (not `ring`) so it paints reliably on interactive `
`s. Tailwind `ring` is `box-shadow`, which browsers often skip on `display: table-row` (notably Safari). Do not put `focus-ring` or raw `ring-*` on a `
`, and do not add `outline-hidden` alongside `focus-inset`. `outline-hidden` sets `outline-style: none` and will hide the indicator.
+
+```txt
+&:focus-visible {
+ outline-style: solid
+ outline-width: 2px
+ outline-offset: -2px
+ outline-color: var(--ring)
+ border-radius: var(--radius-md)
+}
+```
+
+`outline-hidden` is always on (not `focus-visible:`-prefixed) so mouse click does not show the browser’s default outline; the focus indicator replaces it for keyboard focus only.
+
+Rules:
+
+- Prefer `:focus-visible` over `:focus` so click/tap does not show a focus indicator
+- Never use `outline-none` / `outline-hidden` without a ring or outline replacement
+- Always use the shared color (`ring-ring` / `outline-ring`). Variants (primary, danger, warning) do not change focus colour
+- Do not animate the focus indicator; avoid `transition-all` / `transition` on controls that show one (prefer `transition-colors`)
+- Prefer `focus-ring` / `focus-inset` over copy-pasting the class stack
+- On interactive `
`s, use `focus-inset` only. `focus-ring` will look fine in some browsers and invisible in others
+
+When the focused element is not the thing that should show the ring (e.g. a wrapping `Link` with `group`, or an `InputGroup` parent using `:has()`), keep the explicit `group-focus-visible:ring-*` / `has-[…]:focus-visible:ring-*` stack. The utilities bake in `:focus-visible` on the same element and do not compose as `group-focus-visible:focus-ring`.
+
+[Button](components/button) has focus, `tabIndex`, and the shared ring built-in. The same explicit `tabIndex` default is also baked into Checkbox, Switch, Select Trigger, Toggle, Accordion Trigger, Collapsible Trigger, Dropdown Menu Trigger, Popover Trigger, Dialog Trigger, Sheet Trigger, Alert Dialog Trigger, and the Sidebar Menu and action buttons. Bespoke interactive elements however, such as the below interactive [Table Row](components/table#examples), require these props to be added manually:
```tsx showLineNumbers {4-14}
{
if (event.currentTarget !== event.target) return
handleBucketNavigation(bucket.id, event)
diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx
index 43afbfad43f..ffa96da53df 100644
--- a/apps/design-system/content/docs/components/button.mdx
+++ b/apps/design-system/content/docs/components/button.mdx
@@ -144,5 +144,6 @@ Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions,
- Enabled buttons default to `tabIndex={0}` (keyboard accessible)
- Disabled buttons default to `tabIndex={-1}` (removed from tab order)
- You can still override with an explicit `tabIndex` prop when needed
+- Keyboard focus uses the shared `focus-ring` utility; variants do not change ring colour
You therefore don't need to manually set `tabIndex`, as Button handles it automatically based on its `disabled` state.
diff --git a/apps/design-system/content/docs/components/table.mdx b/apps/design-system/content/docs/components/table.mdx
index f25a80fe12f..d0ab5ccb536 100644
--- a/apps/design-system/content/docs/components/table.mdx
+++ b/apps/design-system/content/docs/components/table.mdx
@@ -221,7 +221,7 @@ Avoid adding other actions when using row-level navigation, as multiple interact
When implementing row-level navigation, pay close attention to [Accessibility](/accessibility#focus-management) requirements. The row must be keyboard accessible with proper focus management. Also consider these affordances:
- Handle `Enter` and `Space` key presses for activation
-- Provide visual focus indicators using classes like `inset-focus`
+- Provide visual focus indicators using classes like `focus-inset`
- Support modifier keys (`Ctrl`/`Cmd`, middle-click) for opening links in new tabs
- Consider using the shared `createNavigationHandler` function to handle modifier keys
- Avoid bubbling up action events from _within_ the row
diff --git a/apps/design-system/registry/default/example/table-row-link-actions.tsx b/apps/design-system/registry/default/example/table-row-link-actions.tsx
index 7e744df1b89..5e366aec446 100644
--- a/apps/design-system/registry/default/example/table-row-link-actions.tsx
+++ b/apps/design-system/registry/default/example/table-row-link-actions.tsx
@@ -70,7 +70,7 @@ export default function TableRowLinkActions() {
{policies.map((policy) => (
{
if (event.currentTarget !== event.target) return
handlePolicyNavigation(policy.id, event)
diff --git a/apps/design-system/registry/default/example/table-row-link.tsx b/apps/design-system/registry/default/example/table-row-link.tsx
index be4716eff24..25850a88203 100644
--- a/apps/design-system/registry/default/example/table-row-link.tsx
+++ b/apps/design-system/registry/default/example/table-row-link.tsx
@@ -54,7 +54,7 @@ export default function TableRowLink() {
{buckets.map((bucket) => (
{
if (event.currentTarget !== event.target) return
handleBucketNavigation(bucket.id, event)
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
index 470e5620f97..db84ba3159e 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
@@ -62,7 +62,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
onAuxClick={handleNavigation}
onKeyDown={handleNavigation}
tabIndex={0}
- className="cursor-pointer inset-focus"
+ className="cursor-pointer focus-inset"
>
{item.name}
diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx
index a8e20c56d36..967a4a252fb 100644
--- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx
+++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx
@@ -75,7 +75,7 @@ export const ProjectTableRow = ({
return (
<>
(
onOpenDelivery(row.original.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx
index 766c1997268..732202461f9 100644
--- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx
@@ -170,7 +170,7 @@ export const PlatformWebhooksEndpointList = ({
return (
{
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx
index ef3505c7b2e..92c594863c3 100644
--- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx
+++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx
@@ -154,7 +154,7 @@ export const AnalyticsBuckets = () => {
return (
td]:hover:bg-transparent cursor-not-allowed'
)}
onClick={handleRowActivate}
diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx
index 8fc7e89519e..5ce3d537792 100644
--- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx
+++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketTable.tsx
@@ -107,7 +107,7 @@ export const BucketTableRow = ({
{
return (
{
trackViewPolicies()
handleNavigation(event)
diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx
index 23240722b91..c66a3a003a1 100644
--- a/apps/studio/components/layouts/Tabs/SortableTab.tsx
+++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx
@@ -158,7 +158,7 @@ export const SortableTab = ({
'absolute top-1/2 right-2.5 z-10 -translate-y-1/2',
'flex size-5 items-center justify-center rounded-xs',
'opacity-0 group-hover/tab:opacity-100 group-focus-within/tab:opacity-100 focus-visible:opacity-100',
- 'hover:bg-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+ 'hover:bg-200 focus-ring',
'cursor-pointer'
)}
>
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx
index 72b06e913ac..9ee549892b3 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.tsx
+++ b/apps/studio/components/layouts/Tabs/Tabs.tsx
@@ -259,7 +259,7 @@ export const EditorTabs = () => {
className={cn(
'absolute top-1/2 right-3 z-10 flex -translate-y-1/2 items-center justify-center rounded-xs',
'opacity-0 group-hover/new-tab:opacity-100 group-focus-within/new-tab:opacity-100 focus-visible:opacity-100',
- 'hover:bg-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+ 'hover:bg-200 focus-ring',
'cursor-pointer'
)}
>
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
index 01d5f1d1967..2ce56bf564b 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
@@ -153,7 +153,7 @@ export function DataTableFilterCheckbox({
className={cn(
'text-xs text-muted-foreground hover:text-foreground',
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex group-focus-within:flex items-center cursor-pointer',
- 'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
+ 'focus-ring'
)}
>
Only
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx
index a8ba1cea405..6b44f955cdf 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx
@@ -123,7 +123,7 @@ export function DataTableFilterCheckboxAsync({
className={cn(
'text-xs text-muted-foreground hover:text-foreground',
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex group-focus-within:flex items-center cursor-pointer',
- 'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
+ 'focus-ring'
)}
>
Only
diff --git a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx
index d832a349511..207f4dc9687 100644
--- a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx
@@ -187,12 +187,7 @@ export function DataTableSheetRowAction {
if (e.key === 'ArrowDown') {
// REMINDER: default behavior is to open the dropdown menu
diff --git a/apps/studio/components/ui/DataTable/primitives/Slider.tsx b/apps/studio/components/ui/DataTable/primitives/Slider.tsx
index a5ab8302b15..c3b0cec7aae 100644
--- a/apps/studio/components/ui/DataTable/primitives/Slider.tsx
+++ b/apps/studio/components/ui/DataTable/primitives/Slider.tsx
@@ -22,7 +22,7 @@ export const Slider = forwardRef<
{initialValue.map((_, index) => (
-
+
))}
diff --git a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx
index a1c2ecb786a..26ee2b57cbf 100644
--- a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx
+++ b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx
@@ -85,7 +85,7 @@ export const ProductMenuItem = ({
}
const link = (
-
+
{menuItem}
)
diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx
index b4559f72dbd..f7b7474a307 100644
--- a/apps/studio/pages/_app.tsx
+++ b/apps/studio/pages/_app.tsx
@@ -1,5 +1,4 @@
import '@/styles/code.css'
-import '@/styles/focus.css'
import '@/styles/globals.css'
import '@/styles/graphiql-base.css'
import '@/styles/grid.css'
diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx
index a8b53eb521b..1e5542d5c7f 100644
--- a/apps/studio/routes/__root.tsx
+++ b/apps/studio/routes/__root.tsx
@@ -2,7 +2,6 @@
import 'react-data-grid/lib/styles.css'
import '@/styles/code.css'
-import '@/styles/focus.css'
// Vite-only: defines @font-face for the custom fonts. The Next pipeline
// (pages/_app.tsx) loads these via next/font instead, so this import has no
// counterpart there — but dropping it under Vite makes the browser fall back
diff --git a/apps/studio/styles/focus.css b/apps/studio/styles/focus.css
deleted file mode 100644
index 605031f36d1..00000000000
--- a/apps/studio/styles/focus.css
+++ /dev/null
@@ -1,6 +0,0 @@
-@reference "./globals.css";
-
-/* Focus styles for keyboard navigation on interactive table rows */
-.inset-focus {
- @apply ease-out duration-100 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-600 focus-visible:rounded-md transition-all;
-}
diff --git a/packages/config/css/utilities.css b/packages/config/css/utilities.css
index 7ea8e9d28a1..473245def85 100644
--- a/packages/config/css/utilities.css
+++ b/packages/config/css/utilities.css
@@ -180,3 +180,29 @@
-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
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);
+ }
+}
diff --git a/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx b/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx
index 415350946f8..30fabea2155 100644
--- a/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx
+++ b/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx
@@ -160,7 +160,7 @@ function CommandMenuTrigger({ children }: PropsWithChildren) {
'rounded-md border border-input bg-background',
'text-sm',
'hover:bg-accent hover:text-accent-foreground',
- 'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
+ 'focus-ring',
'disabled:pointer-events-none disabled:opacity-50',
'transition-colors',
childFromProps.props.className
@@ -182,6 +182,7 @@ function CommandMenuTriggerInput({