diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx
index efdede1da8f..c1660c5b963 100644
--- a/apps/design-system/content/docs/accessibility.mdx
+++ b/apps/design-system/content/docs/accessibility.mdx
@@ -59,16 +59,17 @@ focus-visible:ring-offset-background
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
+outline: 2px solid transparent
+outline-offset: -2px
+transition-property: color, background-color, border-color, ...
+
&: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.
+`focus-ring` keeps `outline-hidden` always on so mouse clicks do not show the browser’s default outline. `focus-inset` reserves a transparent outline instead. Its transition property list deliberately excludes outline properties so the keyboard focus indicator appears immediately, even when a call site uses `transition-all`.
Rules:
diff --git a/packages/config/css/utilities.css b/packages/config/css/utilities.css
index 473245def85..64ca4539e5f 100644
--- a/packages/config/css/utilities.css
+++ b/packages/config/css/utilities.css
@@ -198,10 +198,15 @@
}
@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-style: solid;
- outline-width: 2px;
- outline-offset: -2px;
outline-color: var(--ring);
border-radius: var(--radius-md, 0.375rem);
}
diff --git a/packages/ui/src/components/shadcn/ui/accordion.test.tsx b/packages/ui/src/components/shadcn/ui/accordion.test.tsx
new file mode 100644
index 00000000000..734c3e813ec
--- /dev/null
+++ b/packages/ui/src/components/shadcn/ui/accordion.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import { Accordion, AccordionItem, AccordionTrigger } from './accordion'
+
+describe('AccordionTrigger', () => {
+ it('provides rounded geometry and the shared inset focus ring', () => {
+ render(
+
+
+ Advanced settings
+
+
+ )
+
+ const trigger = screen.getByRole('button', { name: 'Advanced settings' })
+ expect(trigger).toHaveClass('px-2', 'relative', 'focus-inset')
+ expect(trigger).not.toHaveClass('rounded-md')
+ expect(trigger).not.toHaveClass('focus-ring')
+ expect(trigger).not.toHaveClass('transition-colors')
+ expect(trigger).not.toHaveClass('transition-all')
+ })
+
+ it('supports an outer focus ring when explicitly requested', () => {
+ render(
+
+
+ Advanced settings
+
+
+ )
+
+ const trigger = screen.getByRole('button', { name: 'Advanced settings' })
+ expect(trigger).toHaveClass('rounded-md', 'focus-ring')
+ expect(trigger).not.toHaveClass('focus-inset')
+ })
+})
diff --git a/packages/ui/src/components/shadcn/ui/accordion.tsx b/packages/ui/src/components/shadcn/ui/accordion.tsx
index 0585a052ac5..cbfe1d8bf66 100644
--- a/packages/ui/src/components/shadcn/ui/accordion.tsx
+++ b/packages/ui/src/components/shadcn/ui/accordion.tsx
@@ -19,41 +19,51 @@ AccordionItem.displayName = 'AccordionItem'
const AccordionTrigger = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef & { hideIcon?: boolean }
->(({ className, children, hideIcon, disabled, tabIndex, ...props }, ref) => {
- const computedTabIndex = getExplicitTabIndex(tabIndex, disabled)
+ React.ComponentPropsWithoutRef & {
+ focusVariant?: 'ring' | 'inset'
+ hideIcon?: boolean
+ }
+>(
+ (
+ { className, children, focusVariant = 'inset', hideIcon, disabled, tabIndex, ...props },
+ ref
+ ) => {
+ const computedTabIndex = getExplicitTabIndex(tabIndex, disabled)
- return (
-
-
-
svg]:rotate-180',
- className
- )}
- {...props}
- disabled={disabled}
- tabIndex={computedTabIndex}
- >
- {children}
- {!hideIcon && (
-
- )}
-
-
-
- )
-})
+ return (
+
+
+
svg]:rotate-180',
+ focusVariant === 'ring' && 'rounded-md',
+ className,
+ focusVariant === 'ring' ? 'focus-ring' : 'relative focus-inset'
+ )}
+ {...props}
+ disabled={disabled}
+ tabIndex={computedTabIndex}
+ >
+ {children}
+ {!hideIcon && (
+
+ )}
+
+
+
+ )
+ }
+)
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
diff --git a/packages/ui/src/components/shadcn/ui/collapsible.tsx b/packages/ui/src/components/shadcn/ui/collapsible.tsx
index c129f72c8ac..d06b8300753 100644
--- a/packages/ui/src/components/shadcn/ui/collapsible.tsx
+++ b/packages/ui/src/components/shadcn/ui/collapsible.tsx
@@ -3,6 +3,7 @@
import { Collapsible as CollapsiblePrimitive } from 'radix-ui'
import * as React from 'react'
+import { cn } from '../../../lib/utils/cn'
import { getExplicitTabIndex } from '../../../lib/utils/getExplicitTabIndex'
const Collapsible = CollapsiblePrimitive.Root
@@ -10,12 +11,13 @@ const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ disabled, tabIndex, ...props }, ref) => {
+>(({ className, disabled, tabIndex, ...props }, ref) => {
const computedTabIndex = getExplicitTabIndex(tabIndex, disabled)
return (