Layered sections of content where one panel shows at a time.
Three deploys this week, all successful.
The last change was merged two hours ago.
Region, alerts and the people who can deploy.
View codeHide code
import { Tabs, TabsList, TabsPanel, TabsTrigger } from '@syncclouds/ui'
export function TabsDemo() {
return (
<Tabs defaultValue="overview" className="w-full max-w-md">
<TabsList aria-label="Project">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsPanel value="overview">Three deploys this week, all successful.</TabsPanel>
<TabsPanel value="activity">The last change was merged two hours ago.</TabsPanel>
<TabsPanel value="settings">Region, alerts and the people who can deploy.</TabsPanel>
</Tabs>
)
}Installation
Available at launch
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uiAvailable at launch
pnpm dlx shadcn@latest add https://ui.syncclouds.com/r/tabs.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/tabs.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/tabs.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/tabs.jsonThe CLI copies 5 files into your ui folder.
1. Copy these files into one folder
tabs.tsxShowHide
'use client'
import {
createContext,
use,
useId,
type ComponentPropsWithRef,
type KeyboardEvent
} from 'react'
import { Slot } from './slot'
import { useControllableState } from './sui-use-controllable-state'
import { idSafe, nextIndex, ownItems, type Orientation } from './sui-keyboard'
interface TabsContextValue {
baseId: string
value: string
setValue: (value: string) => void
orientation: Orientation
activationMode: 'automatic' | 'manual'
}
const TabsContext = createContext<TabsContextValue | null>(null)
const useTabs = (part: string) => {
const context = use(TabsContext)
if (!context) throw new Error(`<${part}> must be used inside <Tabs>`)
return context
}
const triggerId = (baseId: string, value: string) =>
`${baseId}-tab-${idSafe(value)}`
const panelId = (baseId: string, value: string) =>
`${baseId}-panel-${idSafe(value)}`
type TabsValueProps =
| { value: string; onValueChange: (value: string) => void; defaultValue?: never }
| { defaultValue: string; onValueChange?: (value: string) => void; value?: never }
export type TabsProps = Omit<
ComponentPropsWithRef<'div'>,
'defaultValue' | 'onChange'
> &
TabsValueProps & {
orientation?: Orientation
/** automatic: arrow keys select as they move. manual: Enter or Space selects. */
activationMode?: 'automatic' | 'manual'
}
/** Tabs (APG Tabs). One panel shows at a time; hidden panels stay in the DOM. */
export function Tabs({
value,
defaultValue,
onValueChange,
orientation = 'horizontal',
activationMode = 'automatic',
children,
...props
}: TabsProps) {
const baseId = useId()
const [current, setCurrent] = useControllableState({
prop: value,
defaultProp: defaultValue ?? '',
onChange: onValueChange
})
return (
<TabsContext
value={{
baseId,
value: current,
setValue: setCurrent,
orientation,
activationMode
}}
>
<div
{...props}
data-scope="tabs"
data-part="root"
data-orientation={orientation}
>
{children}
</div>
</TabsContext>
)
}
export function TabsList({
onKeyDown,
...props
}: ComponentPropsWithRef<'div'>) {
const { orientation, activationMode, setValue } = useTabs('TabsList')
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const tabs = ownItems(
event.currentTarget,
'[role="tab"]',
'[data-scope="tabs"][data-part="list"]'
)
const index = tabs.indexOf(document.activeElement as HTMLElement)
const next = nextIndex(event.key, index, tabs.length, orientation)
if (next === null) return
event.preventDefault()
tabs[next].focus()
if (activationMode === 'automatic' && tabs[next].dataset.value) {
setValue(tabs[next].dataset.value)
}
}
return (
<div
{...props}
role="tablist"
aria-orientation={orientation}
data-scope="tabs"
data-part="list"
data-orientation={orientation}
onKeyDown={handleKeyDown}
/>
)
}
export interface TabsTriggerProps
extends Omit<ComponentPropsWithRef<'button'>, 'value'> {
value: string
asChild?: boolean
}
export function TabsTrigger({
value,
asChild,
disabled,
onClick,
...props
}: TabsTriggerProps) {
const { baseId, value: current, setValue } = useTabs('TabsTrigger')
const selected = current === value
const Comp = asChild ? Slot : 'button'
return (
<Comp
type={asChild ? undefined : 'button'}
{...props}
role="tab"
id={triggerId(baseId, value)}
aria-selected={selected}
aria-controls={panelId(baseId, value)}
tabIndex={selected ? 0 : -1}
disabled={disabled}
data-scope="tabs"
data-part="trigger"
data-state={selected ? 'active' : 'inactive'}
data-disabled={disabled ? '' : undefined}
data-value={value}
onClick={event => {
onClick?.(event)
if (!event.defaultPrevented) setValue(value)
}}
/>
)
}
export interface TabsPanelProps extends ComponentPropsWithRef<'div'> {
value: string
}
export function TabsPanel({ value, ...props }: TabsPanelProps) {
const { baseId, value: current } = useTabs('TabsPanel')
const selected = current === value
return (
<div
tabIndex={0}
{...props}
role="tabpanel"
id={panelId(baseId, value)}
aria-labelledby={triggerId(baseId, value)}
hidden={!selected}
data-scope="tabs"
data-part="panel"
data-state={selected ? 'active' : 'inactive'}
/>
)
}
slot.tsxShowHide
import {
Children,
cloneElement,
isValidElement,
type CSSProperties,
type ReactElement,
type ReactNode,
type Ref
} from 'react'
import { composeRefs } from './sui-refs'
type AnyProps = Record<string, unknown>
/**
* Renders its only child with the slot's props merged in, so any component
* can take over the element (`asChild`). Event handlers run child first,
* then the slot's, unless the child's handler called preventDefault.
* Class names join, styles merge, refs compose. No hooks, so it works in
* server components too.
*/
export function Slot({
children,
...slotProps
}: AnyProps & { children?: ReactNode; ref?: Ref<unknown> }) {
const child = Children.only(children)
if (!isValidElement(child)) return null
const element = child as ReactElement<AnyProps>
const childProps = element.props
const merged: AnyProps = { ...slotProps, ...childProps }
for (const key of Object.keys(slotProps)) {
const slotValue = slotProps[key]
const childValue = childProps[key]
if (/^on[A-Z]/.test(key) && typeof slotValue === 'function') {
merged[key] =
typeof childValue === 'function'
? (...args: unknown[]) => {
childValue(...args)
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (!event?.defaultPrevented) slotValue(...args)
}
: slotValue
} else if (key === 'className') {
merged.className = [slotValue, childValue].filter(Boolean).join(' ')
} else if (key === 'style') {
merged.style = {
...(slotValue as CSSProperties),
...(childValue as CSSProperties)
}
}
}
/* Only touch the ref when there is one: a server component rendering
<Slot><Link /></Slot> must not hand a function ref to a client child. */
const slotRef = slotProps.ref as Ref<unknown> | undefined
const childRef = childProps.ref as Ref<unknown> | undefined
if (slotRef && childRef) merged.ref = composeRefs(slotRef, childRef)
else if (slotRef || childRef) merged.ref = slotRef ?? childRef
else delete merged.ref
return cloneElement(element, merged)
}
sui-refs.tsShowHide
import { useCallback, type Ref, type RefCallback } from 'react'
const setRef = <T,>(ref: Ref<T> | undefined, value: T | null) => {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
/** One callback ref that feeds several refs. */
export const composeRefs =
<T,>(...refs: (Ref<T> | undefined)[]): RefCallback<T> =>
node => {
for (const ref of refs) setRef(ref, node)
}
/** A user's ref and a component's own ref, as one stable callback ref. */
export const useComposedRefs = <T,>(
outer: Ref<T> | undefined,
inner: Ref<T> | undefined
) => useCallback((node: T | null) => composeRefs(outer, inner)(node), [outer, inner])
sui-use-controllable-state.tsShowHide
import { useCallback, useState } from 'react'
/**
* Controlled when `prop` is defined, uncontrolled otherwise. `onChange`
* fires for every change either way.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange
}: {
prop: T | undefined
defaultProp: T
onChange?: (value: T) => void
}) {
const [uncontrolled, setUncontrolled] = useState(defaultProp)
const controlled = prop !== undefined
const value = controlled ? prop : uncontrolled
const setValue = useCallback(
(next: T) => {
if (!controlled) setUncontrolled(next)
if (!Object.is(next, value)) onChange?.(next)
},
[controlled, onChange, value]
)
return [value, setValue] as const
}
sui-keyboard.tsShowHide
import { useRef } from 'react'
export type Orientation = 'horizontal' | 'vertical'
/**
* The next index for a list navigated with arrow keys, Home and End, or
* null when the key is not a navigation key. Lists wrap, as the APG
* patterns used here recommend. `both` accepts all four arrows (radio).
*/
export function nextIndex(
key: string,
current: number,
count: number,
orientation: Orientation | 'both'
): number | null {
if (count === 0) return null
const forward =
(orientation !== 'horizontal' && key === 'ArrowDown') ||
(orientation !== 'vertical' && key === 'ArrowRight')
const backward =
(orientation !== 'horizontal' && key === 'ArrowUp') ||
(orientation !== 'vertical' && key === 'ArrowLeft')
if (key === 'Home') return 0
if (key === 'End') return count - 1
if (forward) return current < 0 ? 0 : (current + 1) % count
if (backward) return current < 0 ? count - 1 : (current - 1 + count) % count
return null
}
/**
* Items that belong to this container, skipping disabled ones and any that
* sit inside a nested instance of the same component.
*/
export function ownItems(
container: HTMLElement,
itemSelector: string,
containerSelector: string
): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(itemSelector)).filter(
el =>
el.closest(containerSelector) === container &&
!el.hasAttribute('disabled') &&
el.getAttribute('aria-disabled') !== 'true'
)
}
/**
* Typeahead: printable keys typed within half a second build a search
* string, and the first matching label after the current item wins.
*/
export function useTypeahead() {
const buffer = useRef('')
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
return (key: string, labels: string[], current: number): number | null => {
if (key.length !== 1 || key === ' ') return null
clearTimeout(timer.current)
timer.current = setTimeout(() => (buffer.current = ''), 500)
buffer.current += key.toLowerCase()
const search = buffer.current
const repeated = search.split('').every(char => char === search[0])
const query = repeated ? search[0] : search
const start = repeated ? current + 1 : current
for (let i = 0; i < labels.length; i++) {
const index = (start + i + labels.length) % labels.length
if (labels[index].trim().toLowerCase().startsWith(query)) return index
}
return null
}
}
/** Makes a value safe to use inside an id attribute. */
export const idSafe = (value: string) => value.replace(/[^\w-]/g, '_')
2. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import { Tabs, TabsList, TabsPanel, TabsTrigger } from '@syncclouds/ui'<Tabs defaultValue="overview">
<TabsList aria-label="Project">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsPanel value="overview">Overview content</TabsPanel>
<TabsPanel value="activity">Activity content</TabsPanel>
</Tabs>Anatomy
Tabs
├── TabsList
│ └── TabsTrigger
└── TabsPanelVertical
With orientation="vertical", Up and Down move between tabs.
Your name, photo and the email we use to reach you.
Password, two-step sign-in and active sessions.
Which alerts you get, and where.
View codeHide code
import { Tabs, TabsList, TabsPanel, TabsTrigger } from '@syncclouds/ui'
const SECTIONS = [
{ value: 'profile', label: 'Profile', body: 'Your name, photo and the email we use to reach you.' },
{ value: 'security', label: 'Security', body: 'Password, two-step sign-in and active sessions.' },
{ value: 'notifications', label: 'Notifications', body: 'Which alerts you get, and where.' }
]
/* Vertical tabs move with Up and Down instead of Left and Right. */
export function TabsVertical() {
return (
<Tabs defaultValue="profile" orientation="vertical" className="flex w-full max-w-lg gap-6">
<TabsList aria-label="Account settings" className="shrink-0">
{SECTIONS.map(section => (
<TabsTrigger key={section.value} value={section.value}>
{section.label}
</TabsTrigger>
))}
</TabsList>
{SECTIONS.map(section => (
<TabsPanel key={section.value} value={section.value} className="pt-2">
{section.body}
</TabsPanel>
))}
</Tabs>
)
}Manual activation
Arrow keys move focus and Enter or Space selects. Use it when showing a panel is slow.
Traffic for today.
Traffic for the last seven days.
Traffic for the last 30 days.
View codeHide code
import { Tabs, TabsList, TabsPanel, TabsTrigger } from '@syncclouds/ui'
/* Manual activation: arrows move focus, Enter or Space selects. Useful
when showing a panel is slow, such as loading a chart. */
export function TabsManual() {
return (
<Tabs defaultValue="day" activationMode="manual" className="w-full max-w-md">
<TabsList aria-label="Report range">
<TabsTrigger value="day">Day</TabsTrigger>
<TabsTrigger value="week">Week</TabsTrigger>
<TabsTrigger value="month">Month</TabsTrigger>
</TabsList>
<TabsPanel value="day">Traffic for today.</TabsPanel>
<TabsPanel value="week">Traffic for the last seven days.</TabsPanel>
<TabsPanel value="month">Traffic for the last 30 days.</TabsPanel>
</Tabs>
)
}Without the theme
The code view.
The preview view.
The tests view.
View codeHide code
import { Tabs, TabsList, TabsPanel, TabsTrigger } from '@syncclouds/ui'
const TABS = ['Code', 'Preview', 'Tests']
export function TabsUnstyled() {
return (
<Tabs defaultValue="Code" className="w-full max-w-md">
<TabsList aria-label="View" className="flex gap-4 border-b border-zinc-200 dark:border-zinc-700">
{TABS.map(tab => (
<TabsTrigger
key={tab}
value={tab}
className="-mb-px cursor-pointer border-b-2 border-transparent pb-2 text-sm font-medium text-zinc-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 data-[state=active]:border-indigo-600 data-[state=active]:text-zinc-900 dark:text-zinc-400 dark:data-[state=active]:border-indigo-400 dark:data-[state=active]:text-zinc-100"
>
{tab}
</TabsTrigger>
))}
</TabsList>
{TABS.map(tab => (
<TabsPanel key={tab} value={tab} className="pt-4 text-sm text-zinc-700 dark:text-zinc-300">
The {tab.toLowerCase()} view.
</TabsPanel>
))}
</Tabs>
)
}Accessibility
Follows the WAI-ARIA Tabs pattern.
| Keys | What they do |
|---|---|
| Tab | Moves focus to the selected tab, then into the panel. |
| Arrow Left, Arrow Right | Moves between tabs, wrapping at the ends. Up and Down when vertical. |
| Home, End | Moves to the first or last tab. |
| Enter, Space | Selects the focused tab in manual mode. |
- Panels are in the tab order by default, which the APG suggests when a panel has no focusable content. Pass tabIndex={-1} to a panel that starts with a control.
API reference
Every part also accepts the props of the element it renders, including className and ref.
Tabsrenders <div>
Holds the selected tab. Give it either value and onValueChange, or defaultValue.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | The selected tab, when you control it. |
onValueChange | (value: string) => void | none | Called when another tab is selected. |
defaultValue | string | none | The tab selected at first, when the tabs control themselves. |
orientation | "horizontal" | "vertical" | "horizontal" | Sets which arrow keys move between tabs. |
activationMode | "automatic" | "manual" | "automatic" | automatic: arrow keys select as they move. manual: Enter or Space selects. |
| Data attribute | Values |
|---|---|
data-orientation | "horizontal" | "vertical" |
TabsListrenders <div>
The tablist. Name it with aria-label.
| Data attribute | Values |
|---|---|
data-orientation | "horizontal" | "vertical" |
TabsTriggerrenders <button>
One tab.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | Required. Matches a TabsPanel value. |
disabled | boolean | false | Skipped by the arrow keys. |
asChild | boolean | false | Use your own element. |
| Data attribute | Values |
|---|---|
data-state | "active" | "inactive" |
data-disabled | present when disabled |
TabsPanelrenders <div>
The content for one tab. Hidden panels stay in the DOM with the hidden attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | Required. Matches a TabsTrigger value. |
| Data attribute | Values |
|---|---|
data-state | "active" | "inactive" |