Underline Tabs
Text tabs with an underline that slides to the active tab while the panel slides in from the direction you moved.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/underline-tabs.json'use client'
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import './underline-tabs.css'
export interface UnderlineTab { id: string; label: string; count?: number; content: ReactNode }
export interface UnderlineTabsProps {
tabs: UnderlineTab[]
value?: string
defaultValue?: string
onValueChange?: (id: string) => void
showCounts?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Tabs with a sliding underline and directional panels. */
export function UnderlineTabs({ tabs, value, defaultValue, onValueChange, showCounts = true, fill = 'dark', className }: UnderlineTabsProps) {
const [inner, setInner] = useState(defaultValue ?? tabs[0]?.id)
const v = value ?? inner
const idx = Math.max(0, tabs.findIndex((t) => t.id === v))
const prev = useRef(idx)
const [dir, setDir] = useState<'l' | 'r'>('r')
const refs = useRef<(HTMLButtonElement | null)[]>([])
const [bar, setBar] = useState({ x: 0, w: 0 })
const uid = useId()
useLayoutEffect(() => { const el = refs.current[idx]; if (el) setBar({ x: el.offsetLeft, w: el.offsetWidth }) }, [idx, showCounts])
const go = (i: number) => {
const j = (i + tabs.length) % tabs.length
setDir(j >= prev.current ? 'r' : 'l'); prev.current = j
setInner(tabs[j].id); onValueChange?.(tabs[j].id); refs.current[j]?.focus()
}
const t = tabs[idx]
return (
<div className={['sd-ult', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-ult__list" role="tablist">
{tabs.map((tb, i) => (
<button
key={tb.id}
ref={(el) => { refs.current[i] = el }}
id={`${uid}-t${i}`}
type="button"
role="tab"
aria-selected={i === idx}
aria-controls={`${uid}-p`}
tabIndex={i === idx ? 0 : -1}
className="sd-ult__tab"
onClick={() => go(i)}
onKeyDown={(e) => {
const m: Record<string, number> = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }
if (e.key in m) { e.preventDefault(); go(m[e.key]) }
}}
>
{tb.label}{showCounts && tb.count !== undefined && <span className="sd-ult__c">{tb.count}</span>}
</button>
))}
<span className="sd-ult__bar" aria-hidden style={{ width: bar.w, transform: `translateX(${bar.x}px)` }} />
</div>
<div key={t?.id} id={`${uid}-p`} role="tabpanel" aria-labelledby={`${uid}-t${idx}`} tabIndex={0} className="sd-ult__panel" data-dir={dir}>{t?.content}</div>
</div>
)
}.sd-ult { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ult[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ult button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ult :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ult .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ult { width: min(100%, 330px); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-ult__list { position: relative; display: flex; gap: 18px; padding: 0 16px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; }
.sd-ult__tab { position: relative; height: 46px; font-size: 13.5px; font-weight: 500; color: var(--mute); white-space: nowrap; transition: color 200ms; display: inline-flex; align-items: center; gap: 6px; }
.sd-ult__tab:hover, .sd-ult__tab[aria-selected='true'] { color: var(--fg); }
.sd-ult__c { font-size: 11px; padding: 2px 6px; border-radius: 99px; background: var(--soft); font-variant-numeric: tabular-nums; }
.sd-ult__bar { position: absolute; bottom: -1px; left: 0; height: 2px; border-radius: 2px; background: var(--fg); transition: transform 340ms cubic-bezier(0.3, 1.2, 0.4, 1), width 340ms cubic-bezier(0.3, 1.2, 0.4, 1); }
.sd-ult__panel { padding: 16px; min-height: 150px; animation: sd-ult-r 320ms cubic-bezier(0.2, 1, 0.3, 1); outline: 0; }
.sd-ult__panel[data-dir='l'] { animation-name: sd-ult-l; }
@keyframes sd-ult-r { from { opacity: 0; transform: translateX(24px); } }
@keyframes sd-ult-l { from { opacity: 0; transform: translateX(-24px); } }
@media (prefers-reduced-motion: reduce) { .sd-ult *, .sd-ult *::before, .sd-ult *::after { animation: none !important; transition: none !important; } }Overview
The underline measures and travels to the new tab. The panel content enters from the right when you move right and from the left when you move left, so the order of tabs stays spatial.
Optional counts sit beside labels. Overflowing tab rows scroll sideways on narrow screens.
When to use
- Sections of one page: Overview / Activity / Settings.
When not to use
- Switching data views inline: a segmented control is more compact.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/underline-tabs.json$
npx shadcn@latest add https://sundaeui.dev/r/underline-tabs.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/underline-tabs.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/underline-tabs.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy underline-tabs.tsx and underline-tabs.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { UnderlineTabs } from '@/components/sundae/underline-tabs'
<UnderlineTabs />Props
| Prop | Type | Default |
|---|---|---|
tabs*Tabs and panels. | { id: string; label: string; count?: number; content: ReactNode }[] | — |
value / defaultValueActive tab id. | string | — |
onValueChangeCalled on change. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- WAI-ARIA tabs: tablist, tab with aria-selected and aria-controls, tabpanel labelled by its tab.
- Arrow keys move with automatic activation; Home/End jump.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ult; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next tab
- Home / End
- First / last tab
FAQ
- Is Underline Tabs free?
- Yes. Underline Tabs is free for personal and commercial projects, with full source on this page.
- How do I install Underline Tabs?
- Run npx shadcn@latest add https://sundaeui.dev/r/underline-tabs.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Underline Tabs support keyboard and screen readers?
- WAI-ARIA tabs: tablist, tab with aria-selected and aria-controls, tabpanel labelled by its tab. Arrow keys move with automatic activation; Home/End jump.
- Does Underline Tabs respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All navigation →Vertical Tabs
Navigation · RefinedTaffy Tabs
Navigation · PlayfulOrbit Nav
Navigation · PlayfulSegmented Control
Navigation · RefinedPagination
Navigation · RefinedBreadcrumbs
Navigation · Refined