feat: unify SkipToContent across studio, docs, www, and design-system (#48314)

## What kind of change does this PR introduce?

Feature / a11y polish

## What is the current behavior?

Studio and Docs each had their own skip-to-content link (different
styling and behaviour). www and design-system had none.

## What is the new behavior?

Shared `SkipToContent` in `ui-patterns`, adopted by Studio, Docs, www,
and design-system. Documented as a fragment with a short note under
Accessibility → Jumping ahead.

Tab once to reveal the button (top-left), Enter to jump to a
content-only `<main>`.

| After |
| --- |
| <img width="836" height="324" alt="CleanShot 2026-07-24 at 14 08
47@2x"
src="https://github.com/user-attachments/assets/6df29452-e53a-4eca-8f64-946f2b9f605d"
/> |

## To test

Shared steps for every app: enable Tab key navigation if needed, load
the preview, press **Tab** once — skip button should slide in top-left.
Press **Enter** — focus jumps to main content (no blue ring on
`<main>`). Press **Tab** again — first interactive control in the page
body, not the sidebar/nav. Hover the skip button — solid fill, clear
hover state, no chrome showing through.

- **Studio** —
[preview](https://studio-staging-git-dnywh-featskip-to-content-supabase.vercel.app)
→ sign in → any project page
- **Docs** —
[preview](https://docs-git-dnywh-featskip-to-content-supabase.vercel.app)
→ any docs page with sidebar
- **www** —
[preview](https://zone-www-dot-com-git-dnywh-featskip-to-content-supabase.vercel.app)
→ homepage or any marketing page with the default nav
- **Design system** —
[preview](https://design-system-git-dnywh-featskip-to-content-supabase.vercel.app)
→ any docs page (confirm Tab from content does **not** walk the
sidebar), plus [Skip to Content
fragment](https://design-system-git-dnywh-featskip-to-content-supabase.vercel.app/docs/fragments/skip-to-content)

## Additional context

Follow-up to #47694 / #48303 (Studio) and #47515 (Docs).

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

- **New Features**
- Added a reusable “Skip to content” accessibility link across key
layouts and pages.
- Updated main landmarks to support keyboard focus and skip-link
navigation (`id="main"`).
- **Accessibility**
- Skip links now follow consistent landmark-target conventions and
remain hidden until focused.
- Improved documentation for skip links/jump shortcuts in persistent
chrome layouts.
- **Documentation**
- Added a dedicated Skip to Content fragment, navigation entry, and
expanded accessibility guidance.
  - Updated button description wording in component docs.
- **Tests**
  - Added component tests for SkipToContent.
- **Chores**
  - Exposed SkipToContent via additional public package entry points.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-07-29 06:21:50 +10:00
1 parent 944b41c708
commit 37dded67d1
21 files changed
+228 -59

No files matched your search

@@ -146,7 +146,8 @@ export default function Component() {
{['desktop', 'mobile'].map((key) => {
const chart = key as keyof typeof chartConfig
return (
<button tabIndex={0}
<button
tabIndex={0}
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-surface-100 sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
+11
View File
@@ -2502,6 +2502,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"skip-to-content-demo": {
name: "skip-to-content-demo",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/skip-to-content-demo")),
source: "",
files: ["registry/default/example/skip-to-content-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"page-container-demo": {
name: "page-container-demo",
type: "components:example",
+8 -3
View File
@@ -1,4 +1,5 @@
import { ScrollArea } from 'ui'
import { SkipToContent } from 'ui-patterns/SkipToContent'
import { MobileSidebarSheet } from '@/components/mobile-sidebar-sheet'
import { SideNavigation } from '@/components/side-navigation'
@@ -12,18 +13,22 @@ interface AppLayoutProps {
export default async function AppLayout({ children }: AppLayoutProps) {
return (
<>
<SkipToContent href="#main" />
<TopNavigation />
<MobileSidebarSheet />
<main className="flex-1 max-w-site mx-auto w-full border-l border-r border-b">
<div className="flex-1 max-w-site mx-auto w-full border-l border-r border-b">
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="fixed top-10 z-30 hidden h-[calc(100vh-3rem)] w-full shrink-0 md:sticky md:block border-r">
<ScrollArea className="h-full">
<SideNavigation />
</ScrollArea>
</aside>
{children}
{/* Content-only landmark: sidebar must stay outside so skip/Tab don't land in the nav */}
<main id="main" tabIndex={-1} className="outline-hidden scroll-mt-12 min-w-0">
{children}
</main>
</div>
</main>
</div>
<SiteFooter />
</>
)
+5
View File
@@ -247,6 +247,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/single-value-field-array',
items: [],
},
{
title: 'Skip to Content',
href: '/docs/fragments/skip-to-content',
items: [],
},
],
},
{
@@ -116,11 +116,13 @@ Individual options inside of a group can be reached by arrow keys (↑ ↓ ←
### Jumping ahead
Some keyboard-navigable content may be contain hundreds or thousands of items. Help users jump to specific content with the following mitigation strategies:
Some keyboard-navigable content may contain hundreds or thousands of items. Help users jump to specific content with:
- Search and filtering
- Pagination or virtualization
- “Jump to” shortcuts to skip ahead
- Skip links and “jump to” shortcuts
Apps with persistent header and sidebar chrome should expose a skip link as the first focusable element. Use the shared [Skip to Content](fragments/skip-to-content) fragment which owns the component API, usage sample, and target landmark contract.
## Screen readers
@@ -1,6 +1,6 @@
---
title: Button
description: Displays a button or a component that looks like a button.
description: Displays a button or a link that looks like a button.
featured: true
component: true
---
@@ -0,0 +1,53 @@
---
title: Skip to Content
description: Keyboard-accessible skip link that jumps past chrome to the main landmark.
component: true
fragment: true
---
<ComponentPreview name="skip-to-content-demo" peekCode wide />
Keyboard-accessible skip link composed from [Button](../components/button) for apps with persistent header and sidebar chrome. Hidden until focused via Tab, then slides into view so users can jump past navigation to the main landmark.
The preview above shows the focused appearance. In product apps, the link stays off-screen until keyboard focus.
See [Accessibility](../accessibility#jumping-ahead) for more information on when to use skip links, and how they coexist alongside other navigation aids.
## Usage
```tsx
import { SkipToContent } from 'ui-patterns/SkipToContent'
```
```tsx
<SkipToContent href="#main" />
<main id="main" tabIndex={-1} className="scroll-mt-(--header-height) outline-hidden">
{children}
</main>
```
Place the skip link at the root of the app chrome so Tab reaches it before navigation. The target landmark must be content only. Do not wrap a sidebar inside the same `<main>`, otherwise a Tab after skip will land in the sidebar navigation.
## Props
### `href`
Hash href to the main content landmark, e.g. `#main`.
### `children`
Link label. Defaults to `Skip to content`.
### `className`
Optional classes merged onto the positioning wrapper (useful for demos or layout overrides).
## Target landmark
Callers own the landmark the skip link points at:
- Matching `id`
- `tabIndex={-1}` so Enter moves focus onto the landmark
- `scroll-mt` when a sticky header is present
- `outline-hidden` so the landmark has no visible focus ring. The subsequent Tab will land on the first interactive child
@@ -7,11 +7,13 @@ Supabase has a necessarily complex navigation system to handle multiple products
## Components
### NavMenu
### [Nav Menu](../components/nav-menu)
A horizontal list of related views within a consistent PageLayout context, allowing for clearer page-level organisation. Activating a NavMenu item should trigger a URL change.
[NavMenu component guidelines](../components/nav-menu)
### [Skip To Content](../fragments/skip-to-content)
Keyboard-accessible skip link for apps with persistent header and sidebar chrome. Hidden until focused via Tab, then slides into view so users can jump past navigation to the main landmark. See [Accessibility](../accessibility#jumping-ahead) for broader context.
## Page titles
@@ -0,0 +1,19 @@
import { SkipToContent } from 'ui-patterns/SkipToContent'
export default function SkipToContentDemo() {
return (
<div className="relative w-full overflow-hidden rounded-md border bg-studio">
<div className="flex items-center border-b px-4 py-3 text-sm text-foreground-muted">
Demo header / navigation
</div>
{/* Preview shows the focused appearance; production hides until Tab */}
<SkipToContent href="#skip-demo-main" className="relative left-3 top-2 translate-y-0" />
<main id="skip-demo-main" tabIndex={-1} className="outline-hidden p-6 pt-2">
<p className="text-sm text-foreground-light">
Main content landmark. In a real app, Tab once to reveal the skip link, then Enter to jump
here.
</p>
</main>
</div>
)
}
+5
View File
@@ -1351,6 +1351,11 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/info-tooltip-demo.tsx'],
},
{
name: 'skip-to-content-demo',
type: 'components:example',
files: ['example/skip-to-content-demo.tsx'],
},
{
name: 'page-container-demo',
type: 'components:example',
+3 -2
View File
@@ -2,14 +2,15 @@ import 'ui-patterns/ShimmeringLoader/index.css'
import '../styles/globals.css'
import '../styles/prism-okaidia.css'
import { SkipToContent } from '~/components/SkipToContent'
import { GlobalProviders } from '~/features/app.providers'
import { DOCS_CONTENT_CONTAINER_ID } from '~/features/ui/helpers.constants'
import { TopNavSkeleton } from '~/layouts/MainSkeleton'
import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants'
import { getCustomContent } from '~/lib/custom-content/getCustomContent'
import { TelemetryTagManager } from 'common'
import { genFaviconData } from 'common/MetaFavicons/app-router'
import type { Metadata, Viewport } from 'next'
import { SkipToContent } from 'ui-patterns/SkipToContent'
import { inter, manrope } from '@/fonts'
@@ -53,7 +54,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en" className={`${manrope.variable} ${inter.variable}`} suppressHydrationWarning>
<body>
<SkipToContent />
<SkipToContent href={`#${DOCS_CONTENT_CONTAINER_ID}`} />
<TelemetryTagManager />
<GlobalProviders>
<TopNavSkeleton>{children}</TopNavSkeleton>
-22
View File
@@ -1,22 +0,0 @@
import { DOCS_CONTENT_CONTAINER_ID } from '~/features/ui/helpers.constants'
import Link from 'next/link'
import { Button, cn } from 'ui'
const SkipToContent = () => {
return (
<Button
size="tiny"
variant="default"
asChild
className={cn(
'fixed top-0 left-4 z-[100] w-auto',
'-translate-y-full focus-visible:translate-y-4',
'transition-transform duration-200 ease-out'
)}
>
<Link href={`#${DOCS_CONTENT_CONTAINER_ID}`}>Skip to content</Link>
</Button>
)
}
export { SkipToContent }
+1 -1
View File
@@ -290,7 +290,7 @@ const Container = memo(function Container({
id={DOCS_CONTENT_CONTAINER_ID}
tabIndex={-1}
className={cn(
'w-full transition-all ease-out relative scroll-mt-(--header-height)',
'w-full transition-all ease-out relative scroll-mt-(--header-height) outline-hidden',
// desktop override any margin styles
'lg:ml-0',
className
@@ -1,14 +1,8 @@
import { useBreakpoint, useParams } from 'common'
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect, useState } from 'react'
import {
buttonVariants,
cn,
ResizablePanel,
ResizablePanelGroup,
SidebarProvider,
usePanelRef,
} from 'ui'
import { ResizablePanel, ResizablePanelGroup, SidebarProvider, usePanelRef } from 'ui'
import { SkipToContent } from 'ui-patterns/SkipToContent'
import { BannerStack } from '../ui/BannerStack/BannerStack'
import { LayoutHeader } from './Navigation/LayoutHeader/LayoutHeader'
@@ -101,19 +95,7 @@ export const DefaultLayout = ({
<ProjectContextProvider projectRef={ref}>
<MobileSheetProvider>
<div className="flex flex-col h-screen w-screen">
<a
className={cn(
buttonVariants({
size: 'xlarge',
variant: 'primary',
}),
'absolute top-0 left-1/2 -translate-x-1/2 -translate-y-full focus:translate-y-4'
)}
href="#main"
tabIndex={0}
>
Skip to content
</a>
<SkipToContent href="#main" />
{/* Top Banner */}
<AppBannerWrapper />
<div className="shrink-0">
@@ -144,7 +126,7 @@ export const DefaultLayout = ({
maxSize={`${contentMaxSizePercentage}`}
defaultSize={`${contentMaxSizePercentage}`}
>
<main id="main" className="h-full overflow-y-auto">
<main id="main" tabIndex={-1} className="h-full overflow-y-auto outline-hidden">
{children}
</main>
</ResizablePanel>
+5 -1
View File
@@ -1,15 +1,19 @@
import Footer from '~/components/Footer'
import Nav from '~/components/Nav'
import { SkipToContent } from 'ui-patterns/SkipToContent'
import { ThemeForcer } from './ThemeForcer'
export default function HomeLayout({ children }: { children: React.ReactNode }) {
return (
<>
<SkipToContent href="#main" />
<ThemeForcer />
<Nav hideNavbar={false} />
<div className="relative w-full">
<main className="relative min-h-screen">{children}</main>
<main id="main" tabIndex={-1} className="relative min-h-screen scroll-mt-16 outline-hidden">
{children}
</main>
</div>
<Footer />
</>
+7 -1
View File
@@ -3,6 +3,7 @@ import { GoPageRenderer as MarketingPageRenderer } from 'marketing'
import type { CustomSectionRenderers } from 'marketing'
import Image from 'next/image'
import Link from 'next/link'
import { SkipToContent } from 'ui-patterns/SkipToContent'
import TweetsSection from './TweetsSection'
import type { GoPage } from '@/types/go'
@@ -15,6 +16,7 @@ const customRenderers: CustomSectionRenderers = {
export default function GoPageRenderer({ page }: { page: GoPage }) {
return (
<>
<SkipToContent href="#main" />
<nav className="absolute top-0 left-0 right-0 z-10">
<div className="max-w-7xl mx-auto flex items-center h-14 px-8">
<Link href="/">
@@ -24,7 +26,11 @@ export default function GoPageRenderer({ page }: { page: GoPage }) {
</div>
</nav>
<main className="relative min-h-screen pb-16 sm:pb-24">
<main
id="main"
tabIndex={-1}
className="relative min-h-screen scroll-mt-14 pb-16 outline-hidden sm:pb-24"
>
<MarketingPageRenderer page={page} customRenderers={customRenderers} />
</main>
<footer className="border-t border-muted">
+9 -1
View File
@@ -1,4 +1,5 @@
import { cn } from 'ui'
import { SkipToContent } from 'ui-patterns/SkipToContent'
import Footer from '@/components/Footer/index'
import Nav from '@/components/Nav/index'
@@ -25,9 +26,16 @@ const DefaultLayout = (props: Props) => {
return (
<>
<SkipToContent href="#main" />
<ThemeForcer />
<Nav hideNavbar={hideHeader} stickyNavbar={stickyNavbar} />
<main className={cn('relative min-h-screen', className)}>{children}</main>
<main
id="main"
tabIndex={-1}
className={cn('relative min-h-screen scroll-mt-16 outline-hidden', className)}
>
{children}
</main>
<Footer className={footerClassName} hideFooter={hideFooter} />
</>
)
+8
View File
@@ -594,6 +594,14 @@
"import": "./src/SimpleCodeBlock/prism.ts",
"types": "./src/SimpleCodeBlock/prism.ts"
},
"./SkipToContent/SkipToContent": {
"import": "./src/SkipToContent/SkipToContent.tsx",
"types": "./src/SkipToContent/SkipToContent.tsx"
},
"./SkipToContent": {
"import": "./src/SkipToContent/index.tsx",
"types": "./src/SkipToContent/index.tsx"
},
"./SqlToRest/assumptions": {
"import": "./src/SqlToRest/assumptions.ts",
"types": "./src/SqlToRest/assumptions.ts"
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { SkipToContent } from './SkipToContent'
describe('SkipToContent', () => {
it('renders a link with the provided href and default label', () => {
render(<SkipToContent href="#main" />)
const link = screen.getByRole('link', { name: 'Skip to content' })
expect(link).toHaveAttribute('href', '#main')
})
it('allows a custom label', () => {
render(<SkipToContent href="#docs-content">Skip to docs</SkipToContent>)
expect(screen.getByRole('link', { name: 'Skip to docs' })).toHaveAttribute(
'href',
'#docs-content'
)
})
it('is off-screen until keyboard focus and slides in on focus-within', () => {
const { container } = render(<SkipToContent href="#main" />)
const wrapper = container.firstElementChild as HTMLElement
expect(wrapper.className).toContain('-translate-y-full')
expect(wrapper.className).toContain('focus-within:translate-y-[10px]')
expect(wrapper.className).toContain('bg-background')
expect(wrapper.className).toContain('w-fit')
expect(wrapper.className).toContain('left-[10px]')
})
it('uses an unmodified default Button for hover and fill styles', () => {
render(<SkipToContent href="#main" />)
const link = screen.getByRole('link', { name: 'Skip to content' })
expect(link.className).not.toContain('bg-surface-300')
expect(link.className).not.toContain('hover:bg-secondary')
})
})
@@ -0,0 +1,37 @@
import { type ReactNode } from 'react'
import { Button, cn } from 'ui'
export interface SkipToContentProps {
/** Hash href to the main content landmark, e.g. `#main`. */
href: string
children?: ReactNode
className?: string
}
/**
* Keyboard-accessible skip link. Hidden until focused via Tab, then slides into view.
*
* Callers must provide a matching landmark target with `id`, `tabIndex={-1}`,
* `outline-hidden`, and `scroll-mt` when a sticky header is present. The
* landmark itself should not show a visible focus ring.
*/
function SkipToContent({ href, children = 'Skip to content', className }: SkipToContentProps) {
return (
<div
className={cn(
// Opaque plate so default Button muted/selection fills composite like any other control.
// w-fit: plate is a block div by default and would otherwise span the full content column.
'fixed top-0 left-[10px] z-[100] w-fit rounded-md bg-background',
'-translate-y-full focus-within:translate-y-[10px]',
'transition-transform duration-200 ease-out',
className
)}
>
<Button size="tiny" variant="default" asChild>
<a href={href}>{children}</a>
</Button>
</div>
)
}
export { SkipToContent }
@@ -0,0 +1 @@
export { SkipToContent, type SkipToContentProps } from './SkipToContent'