Skip to content

Conversation

@Kamalllx
Copy link

@Kamalllx Kamalllx commented Nov 26, 2025

Closes #33025

What I did

Added visible :focus-visible styles for interactive elements (links, buttons, inputs, textareas, and selects) within the Docs view to ensure keyboard focus indicators are visible when navigating with Tab/Shift+Tab.

Changes:

  • Added a new style rule in DocsPage.tsx that applies a 2px solid outline using the theme's secondary color to all interactive elements when focused via keyboard
  • Included outline-offset: 2px for better visual separation
  • Added fallback support for high-contrast/forced-colors environments using @media (forced-colors: active)

This fix improves accessibility compliance with WCAG 2.1.1 (Keyboard) and WCAG 2.4.7 (Focus Visible).

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

  1. Run a sandbox: yarn task --task sandbox --start-from auto --template react-vite/default-ts
  2. Open Storybook in your browser at http://localhost:6006
  3. Navigate to any story's Docs view
  4. Use the Tab key to navigate between interactive elements (links, buttons, "Show code", "Copy" buttons)
  5. Verify that each focused element displays a visible outline (2px solid border)
  6. Test with both light and dark themes

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Summary by CodeRabbit

  • Style
    • Enhanced keyboard focus visibility for interactive elements including links, buttons, and form inputs with improved outline styling
    • Added high-contrast mode support for improved visibility in accessibility-focused environments

✏️ Tip: You can customize this high-level summary in your review settings.

…view

Closes storybookjs#33025

Added :focus-visible styles for interactive elements (a, button, input, textarea, select) in DocsPage.tsx to ensure keyboard focus indicators are visible. Includes fallback for high-contrast/forced-colors environments.
@valentinpalkovic valentinpalkovic changed the title fix issue#33025 (docs): add visible focus outline for keyboard navigation in Docs … Docs: Add visible focus outline for keyboard navigation Dec 1, 2025
@Sidnioulz Sidnioulz self-requested a review December 1, 2025 14:21
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 1, 2025

📝 Walkthrough

Walkthrough

Adds keyboard focus visibility styling to interactive elements in DocsContent. Applies a 2px outline using the theme's secondary color with a 2px offset, and includes a high-contrast forced-colors fallback. Single-file change purely enhancing visual accessibility without altering functionality.

Changes

Cohort / File(s) Summary
Keyboard focus visibility styling
code/addons/docs/src/blocks/components/DocsPage.tsx
Adds focus-visible outline styling for anchors, buttons, inputs, textareas, and selects with theme secondary color and high-contrast fallback

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

  • Verify CSS outline values and theme color reference are correct
  • Confirm high-contrast fallback applies appropriately across target elements
  • Test keyboard navigation focus visibility in both standard and high-contrast modes

Possibly related PRs

  • Core: Minor UI fixes #33218: Addresses keyboard-focus outline styling for interactive UI elements, including focus-visible outline adjustments and high-contrast mode handling
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 13d4773 and 2a93351.

📒 Files selected for processing (1)
  • code/addons/docs/src/blocks/components/DocsPage.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{js,jsx,json,html,ts,tsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use ESLint and Prettier configurations that are enforced in the codebase

Files:

  • code/addons/docs/src/blocks/components/DocsPage.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Enable TypeScript strict mode

Files:

  • code/addons/docs/src/blocks/components/DocsPage.tsx
code/**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

code/**/*.{ts,tsx,js,jsx,mjs}: Use server-side logger from 'storybook/internal/node-logger' for Node.js code
Use client-side logger from 'storybook/internal/client-logger' for browser code
Do not use console.log, console.warn, or console.error directly unless in isolated files where importing loggers would significantly increase bundle size

Files:

  • code/addons/docs/src/blocks/components/DocsPage.tsx
code/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Export functions that need to be tested from their modules

Files:

  • code/addons/docs/src/blocks/components/DocsPage.tsx
code/**/*.{js,jsx,json,html,ts,tsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

code/**/*.{js,jsx,json,html,ts,tsx,mjs}: Run Prettier with --write flag to format code before committing
Run ESLint with yarn lint:js:cmd to check for linting issues and fix errors before committing

Files:

  • code/addons/docs/src/blocks/components/DocsPage.tsx
🧠 Learnings (3)
📓 Common learnings
Learnt from: Sidnioulz
Repo: storybookjs/storybook PR: 32458
File: code/core/src/components/components/Tabs/Tabs.stories.tsx:222-227
Timestamp: 2025-11-05T09:36:55.944Z
Learning: Repo: storybookjs/storybook PR: 32458 — In code/core/src/components/components/Button/Button.tsx (React/TypeScript), ButtonProps includes ariaLabel?: string | false and the component maps it to the DOM aria-label. Convention: ariaLabel is mandatory on all Button usages — provide a descriptive string for icon-only buttons; set ariaLabel=false when the button’s children already serve as the accessible name. Do not suggest using a raw aria-label prop on Button call sites.
📚 Learning: 2025-11-05T09:36:55.944Z
Learnt from: Sidnioulz
Repo: storybookjs/storybook PR: 32458
File: code/core/src/components/components/Tabs/Tabs.stories.tsx:222-227
Timestamp: 2025-11-05T09:36:55.944Z
Learning: Repo: storybookjs/storybook PR: 32458 — In code/core/src/components/components/Button/Button.tsx (React/TypeScript), ButtonProps includes ariaLabel?: string | false and the component maps it to the DOM aria-label. Convention: ariaLabel is mandatory on all Button usages — provide a descriptive string for icon-only buttons; set ariaLabel=false when the button’s children already serve as the accessible name. Do not suggest using a raw aria-label prop on Button call sites.

Applied to files:

  • code/addons/docs/src/blocks/components/DocsPage.tsx
📚 Learning: 2025-10-03T07:55:42.639Z
Learnt from: Sidnioulz
Repo: storybookjs/storybook PR: 32458
File: code/core/src/manager/components/preview/Toolbar.tsx:102-105
Timestamp: 2025-10-03T07:55:42.639Z
Learning: In code/core/src/manager/components/preview/Toolbar.tsx, we intentionally do not add aria-label/aria-labelledby to StyledToolbar (AbstractToolbar) because the enclosing section is already labeled via an sr-only heading and the toolbar is the only content. Revisit only if real user testing indicates a need.

Applied to files:

  • code/addons/docs/src/blocks/components/DocsPage.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: normal

Comment on lines +258 to +271
// Ensure keyboard focus is visible for interactive elements inside docs
[toGlobalSelector('a, button, input, textarea, select')]: {
'&:focus-visible': {
outline:
theme.base === 'light'
? `2px solid ${theme.color.secondary}`
: `2px solid ${theme.color.secondary}`,
outlineOffset: '2px',
// fallback for high-contrast / forced-colors environments
'@media (forced-colors: active)': {
outline: '2px solid Highlight',
},
},
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix toGlobalSelector usage so focus styles apply correctly and respect .sb-unstyled

toGlobalSelector is designed for a single element selector, but here it’s called with a comma‑separated list:

[toGlobalSelector('a, button, input, textarea, select')]: { ... }

That expands to roughly:

:where(a, button, input, textarea, select:not(...))

which has two problems:

  1. Escape hatch and anchor protection are bypassed
    The :not(.sb-anchor, .sb-unstyled, .sb-unstyled <element>) part only applies to the last selector (select), so a, button, input, and textarea are matched even inside .sb-unstyled content and on .sb-anchor elements, breaking the documented “unstyled” and anchor behavior.

  2. select elements are never matched
    Because select is also included inside the :not(...) list, select:not(...select...) cannot match any element, so select elements never receive the new :focus-visible outline. This fails the PR goal of covering all listed interactive elements.

To keep the intended semantics and support all target elements, define the rule per element (you can refactor to share the style object if you prefer). For example:

-    // Ensure keyboard focus is visible for interactive elements inside docs
-    [toGlobalSelector('a, button, input, textarea, select')]: {
-      '&:focus-visible': {
-        outline:
-          theme.base === 'light'
-            ? `2px solid ${theme.color.secondary}`
-            : `2px solid ${theme.color.secondary}`,
-        outlineOffset: '2px',
-        // fallback for high-contrast / forced-colors environments
-        '@media (forced-colors: active)': {
-          outline: '2px solid Highlight',
-        },
-      },
-    },
+    // Ensure keyboard focus is visible for interactive elements inside docs
+    const focusVisibleOutline = {
+      '&:focus-visible': {
+        outline: `2px solid ${theme.color.secondary}`,
+        outlineOffset: '2px',
+        // fallback for high-contrast / forced-colors environments
+        '@media (forced-colors: active)': {
+          outline: '2px solid Highlight',
+        },
+      },
+    };
+
+    [toGlobalSelector('a')]: focusVisibleOutline,
+    [toGlobalSelector('button')]: focusVisibleOutline,
+    [toGlobalSelector('input')]: focusVisibleOutline,
+    [toGlobalSelector('textarea')]: focusVisibleOutline,
+    [toGlobalSelector('select')]: focusVisibleOutline,

(If you’d rather not introduce a new const, you can inline focusVisibleOutline into five separate blocks; behaviorally it’s the same.)

Also, note that the theme.base === 'light' ? ... : ... ternary was redundant (both branches identical), so the above simplification keeps behavior but removes that duplication.

🤖 Prompt for AI Agents
In code/addons/docs/src/blocks/components/DocsPage.tsx around lines 258 to 271,
the current single call to toGlobalSelector with a comma-separated list causes
the :not(...) protection to only apply to the last selector and prevents select
from ever matching; split the rule into one toGlobalSelector call per element
(a, button, input, textarea, select) so each element gets its own
:not(.sb-anchor, .sb-unstyled, .sb-unstyled <element>) protection and focus
styles; factor the shared focus-visible style into a small const (including the
forced-colors fallback) and apply that const to each [toGlobalSelector('...')]
entry, and remove the redundant theme.base ternary since both branches are
identical.

Copy link
Member

@Sidnioulz Sidnioulz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR does not really address the issue. It restyles some focus outlines rather than adding missing outlines.

All focus outlines already exist, to the best of my knowledge: we have default outlines for MDX interactive elements, brand new outlines for component controls, and old style outlines for buttons like copy code.

I'll edit the original issue to clarify what is expected. I suggest holding off until the issue is a bit better defined before giving this another go, so we don't waste more of your time!

Comment on lines +259 to +260
[toGlobalSelector('a, button, input, textarea, select')]: {
'&:focus-visible': {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd be better off targeting toGlobalSelector('*:focus-visible'). We're missing things like area, video controls, summary.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An explicit list is definitely going to be more performant than a *, but the downside is the list might be difficult to maintain/remember over time. Just food for thought.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An explicit list is definitely going to be more performant than a *, but the downside is the list might be difficult to maintain/remember over time. Just food for thought.

Generally speaking, we're splitting hairs here and we can ignore the performance overhead. What costs is how often you need to re-evaluate selectors rather than how many selectors you need to evaluate, because the eval is heavily optimised.

In this specific instance, I believe *:focus-visible has the better performance. Selectors will be matched right to left and built into a tree-like index, so

  • For every element in the DOM, the CSS engine will first check if it matches the :focus-visible pseudo-selector
  • Then, for every matching element (of which there is exactly one hence the hair splitting), the CSS engine will match every follow-up selector:
    • either a single match to make against *
    • or ~10ish matches to make against individual elements

Which is why * provides the better performance here (fewer checks to make, as we know only focusable elements can get :focus-visible and *:focus-visible will only apply rules when relevant).

Comment on lines +261 to +264
outline:
theme.base === 'light'
? `2px solid ${theme.color.secondary}`
: `2px solid ${theme.color.secondary}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
outline:
theme.base === 'light'
? `2px solid ${theme.color.secondary}`
: `2px solid ${theme.color.secondary}`,
outline: `2px solid ${theme.color.secondary}`,

outlineOffset: '2px',
// fallback for high-contrast / forced-colors environments
'@media (forced-colors: active)': {
outline: '2px solid Highlight',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
outline: '2px solid Highlight',
outline: '2px solid AccentColor',

According to https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/outline-color, the default outline color is either currentColor or accent-color.

Generally speaking I would prefer to not set a forced colour selector until someone reports an actual problem, though. We need to have some trust in the forced color selector applying system colours properly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sidnioulz Do you think we should avoid setting the color on the focus outline or am I misreading this comment?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specifically talking about the forced colors selector which applies to High Contrast Mode.

I prefer if we don't enforce system colours ourselves until someone reports a problem, because browsers already translate CSS colours into system colours themselves and do a pretty good job of it.

Here for instance, I don't believe @Kamalllx chose the right semantic. Even if we set the right colour, there are a lot of deprecated system colours and a lot of not-yet-baseline system colours, so browser defaults will evolve over time, and we'll miss out on those browser changes when we hardcode system colours.

@Sidnioulz
Copy link
Member

@MichaelArestad I think we should close this PR until we get a chance to work on making the remaining buttons more visually consistent across MDX and addon panels. This doesn't increase consistency, it just changes a colour at the end of the day. WDYT?

@MichaelArestad
Copy link
Contributor

I think we should close this PR until we get a chance to work on making the remaining buttons more visually consistent across MDX and addon panels.

@Sidnioulz I don't follow. Are there major discrepancies across buttons there? How do those relate to this PR?

@Sidnioulz
Copy link
Member

I think we should close this PR until we get a chance to work on making the remaining buttons more visually consistent across MDX and addon panels.

@Sidnioulz I don't follow. Are there major discrepancies across buttons there? How do those relate to this PR?

The fact that the PR does not remove any outline: none calls shows that we just don't style default focus outlines in preview code, but we're not missing focus outlines. We just rely on browser defaults.

So I don't find the assertion that making style changes makes focus visible and allows keyboard navigation. It might improve it but it does not resolve a blocking situation.

The reason I'm not super keen to handle this topic just in MDX CSS is that the overall keyboard navigation experience outside of MDX is already fairly inconsistent:

docs-focus-styles.webm

So if we want to build a consistent keyboard navigation experience (https://www.w3.org/WAI/WCAG21/Understanding/consistent-identification.html), we need to align those styles too, and that requires design input from you, @MichaelArestad.

@MichaelArestad
Copy link
Contributor

@Sidnioulz Fair! That video is solid. Thanks!

@Sidnioulz
Copy link
Member

@Kamalllx thanks for volunteering to help with this issue. I'm gonna go ahead and close the PR because there is a bigger inconsistency we need to fix. If you'd like to contribute a broader fix, please let us know on the issue and Michael will get back to you with designs. Thanks 🙏

@Sidnioulz Sidnioulz closed this Dec 19, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Inconsistent styling of focus indicators in Docs view (WCAG SC 3.2.4)

4 participants