Tabs
A row of tabs on a 1px line. One is selected, and its panel shows below.
import { Tabs } from 'rost/components/tabs/Tabs'
import { TabItem } from 'rost/components/tab-item/TabItem'
<Tabs aria-label="Invoice">
<TabItem selected aria-controls="panel-details">Details</TabItem>
<TabItem aria-controls="panel-history">History</TabItem>
</Tabs>src/components/tabs/Tabs.tsx
import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'
import './tabs.css'
export interface TabsProps extends HTMLAttributes<HTMLDivElement> {
/** What the tabs switch between, for screen readers: "Button preview and code". */
readonly 'aria-label': string
/** The TabItem components. */
readonly children?: ReactNode
}
/**
* A row of tabs on a 1px line. Arrow keys move to the next or previous tab
* and select it, Home and End go to the first and last, the way the ARIA tabs
* pattern has it. Built from contracts/tabs.contract.yaml.
*/
export function Tabs({ children, className, onKeyDown, ...rest }: TabsProps) {
const move = (e: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(e)
const tabs = [...e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)')]
const i = tabs.indexOf(document.activeElement as HTMLButtonElement)
if (i < 0) return
const to = e.key === 'ArrowRight' ? tabs[(i + 1) % tabs.length] : e.key === 'ArrowLeft' ? tabs[(i - 1 + tabs.length) % tabs.length]
: e.key === 'Home' ? tabs[0] : e.key === 'End' ? tabs[tabs.length - 1] : null
if (!to) return
e.preventDefault()
to.focus()
to.click()
}
return <div role="tablist" className={['rost-tabs', className].filter(Boolean).join(' ')} onKeyDown={move} {...rest}>{children}</div>
}When to use
- Above the panels it switches between.
When not to use
- More tabs than fit on one line. Fewer, or a menu.
Behaviour
One tab is selected at a time. Changing it swaps the panel below; nothing else on the page moves.
Accessibility
- Element:
role="tablist"holding the tabs. Each panel is arole="tabpanel"labelled by its tab. - Name: an
aria-labelon the tablist that says what it switches, like "Example". - Keyboard: Tab lands on the selected tab and the next Tab goes into its panel. Arrow keys, Home and End move between tabs (see TabItem).
- Focus: on the tabs themselves, each with its own
focus-ring.
Content
Tabs that belong together, in the order people need them.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| tabs | slot |
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | display | flex | layout |
| root | align-items | flex-start | layout |
| root | gap | 0px | Figma frame size |
| root | box-shadow | inset 0 calc(-1 * var(--spacing-outline-small)) 0 0 var(--tabs-line) | on purpose: Figma's 1px line along the bottom, drawn inside so the row stays 40px tall and the selected tab's underline sits on top of it. The width and colour are tokens (spacing/outline/small, tabs/line). |
TabItem
One tab in a row of tabs. A label, optional icons before and after it, and a green underline when it's the one selected.
Hover, press or tab to one to see the other 6 states.
import { Tabs } from 'rost/components/tabs/Tabs'
import { TabItem } from 'rost/components/tab-item/TabItem'
<Tabs aria-label="Invoice">
<TabItem selected aria-controls="panel-details">Details</TabItem>
<TabItem aria-controls="panel-history">History</TabItem>
</Tabs>src/components/tab-item/TabItem.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import './tab-item.css'
export interface TabItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** The tab whose panel shows. It gets the green underline. */
readonly selected?: boolean
/** A 20px icon before the label. Draw it with `currentColor`. */
readonly leading?: ReactNode
/** A 20px icon after the label. Draw it with `currentColor`. */
readonly trailing?: ReactNode
/** The label. */
readonly children?: ReactNode
/** `span` draws it without the button, for a preview inside something that's already a link. */
readonly as?: 'button' | 'span'
}
const b = 'rost-tab-item'
/**
* One tab, always inside Tabs. A `<button role="tab">`: only the selected one
* is in the tab order, and Tabs moves between them with the arrow keys.
* Built from contracts/tab-item.contract.yaml.
*/
export function TabItem({ selected = false, leading, trailing, children, className, type = 'button', as = 'button', ...rest }: TabItemProps) {
const Root = as
const own = as === 'button' ? { type, role: 'tab', 'aria-selected': selected, tabIndex: selected ? 0 : -1 } : {}
return (
<Root
{...own}
className={[b, selected && `${b}--selected`, className].filter(Boolean).join(' ')}
{...(rest as object)}
>
{leading && <span className={`${b}__leading`} aria-hidden="true">{leading}</span>}
<span className={`${b}__label`}>{children}</span>
{trailing && <span className={`${b}__trailing`} aria-hidden="true">{trailing}</span>}
<span className={`${b}__underline`} />
<span className={`${b}__focus-ring`} />
</Root>
)
}When to use
- Switching between views of the same thing, in place: Preview and Code, Overview and Activity.
When not to use
- Going to another page. That's a link, or the nav.
- Turning one thing on and off. That's Toggle.
Behaviour
Always inside Tabs, and exactly one is selected. Selecting a tab shows its panel straight away. Hover, pressed and focus come from the browser; selected and disabled are props.
Accessibility
- Element:
<button role="tab">witharia-selectedandaria-controls, inside arole="tablist"that has a name. - Name: the label.
- Keyboard: Only the selected tab is in the tab order. Arrow keys move between tabs, Home and End go to the first and last.
- Focus: the
focus-ringpart, on:focus-visible. - Icons: just decoration,
aria-hidden. The label is the name.
Content
One or two words, sentence case. The same kind of thing in every tab.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| label | slot | ||
| selected | boolean | false | |
| leading | slot | ||
| trailing | slot |
States: Default, Hover, Pressed, Focus, Disabled.
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | display | inline-flex | layout |
| root | align-items | center | layout |
| root | justify-content | center | layout |
| root | position | relative | layout |
| root | background | none | Figma frame size |
| root | border | none | Figma frame size |
| root | font | inherit | Figma frame size |
| root | cursor | pointer | Figma frame size |
| root | outline | none | Figma frame size |
| root | gap | 8px | spacing/gap/small |
| root | height | 40px | spacing/size/medium |
| root | padding-left | 16px | spacing/gap/medium |
| root | padding-right | 16px | spacing/gap/medium |
| root | padding-top | 0px | Figma frame size |
| root | padding-bottom | 0px | Figma frame size |
| leading | display | flex | layout |
| leading | flex-shrink | 0 | layout |
| leading | width | 20px | spacing/size/xsmall |
| leading | height | 20px | spacing/size/xsmall |
| label | white-space | nowrap | layout |
| label | font-family | 'General Sans Variable' | font-family/base |
| label | font-weight | 500 | typography/body/font-weight/medium |
| label | line-height | 22px | typography/body/line-height |
| label | font-size | 16px | typography/body/font-size |
| label | letter-spacing | 0px | typography/body/letter-spacing |
| trailing | display | flex | layout |
| trailing | flex-shrink | 0 | layout |
| trailing | width | 20px | spacing/size/xsmall |
| trailing | height | 20px | spacing/size/xsmall |
| underline | position | absolute | layout |
| underline | left | 0 | Figma frame size |
| underline | right | 0 | Figma frame size |
| underline | bottom | 0 | Figma frame size |
| underline | height | 2px | spacing/outline/medium |
| underline | pointer-events | none | Figma frame size |
| focus-ring | display | none | layout |
| focus-ring | position | absolute | layout |
| focus-ring | inset | 0 | layout |
| focus-ring | border-style | solid | no token yet |
| focus-ring | border-width | 2px | spacing/outline/medium |
| focus-ring | border-radius | 8px | spacing/radius/small |
| focus-ring | pointer-events | none | Figma frame size |
Changes by variant
focus-ring.border-color2
| state=Focus, selected=True | #b6ffda | tabs/selected/focus/focus-ring |
| state=Focus, selected=False | #999999 | tabs/unselected/focus/focus-ring |
focus-ring.display2
| state=Focus, selected=True | block | layout |
| state=Focus, selected=False | block | layout |
root.color10
| state=Default, selected=True | #ededed | tabs/selected/default/label |
| state=Hover, selected=True | #ededed | tabs/selected/hover/label |
| state=Pressed, selected=True | #ededed | tabs/selected/pressed/label |
| state=Focus, selected=True | #ededed | tabs/selected/focus/label |
| state=Disabled, selected=True | #d3d9d5 | tabs/selected/disabled/label |
| state=Default, selected=False | #999999 | tabs/unselected/default/label |
| state=Hover, selected=False | #ededed | tabs/unselected/hover/label |
| state=Pressed, selected=False | #d3d9d5 | tabs/unselected/pressed/label |
| state=Focus, selected=False | #d3d9d5 | tabs/unselected/focus/label |
| state=Disabled, selected=False | #999999 | tabs/unselected/disabled/label |
root.opacity2
| state=Disabled, selected=True | 0.48 | opacity/medium |
| state=Disabled, selected=False | 0.48 | opacity/medium |
underline.background7
| state=Default, selected=True | #00d669 | tabs/selected/default/underline |
| state=Hover, selected=True | #00b450 | tabs/selected/hover/underline |
| state=Pressed, selected=True | #00913a | tabs/selected/pressed/underline |
| state=Focus, selected=True | #00d669 | tabs/selected/focus/underline |
| state=Disabled, selected=True | #00d669 | tabs/selected/disabled/underline |
| state=Hover, selected=False | #3a3a3a | tabs/unselected/hover/underline |
| state=Pressed, selected=False | #747573 | tabs/unselected/pressed/underline |