Tabs
Pill and underline tabs with a spring-animated indicator measured from the active tab. Arrow keys supported.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/components-tabs.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ui/core/Tabs.tsx
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { Bell, CreditCard, Settings, Users } from 'lucide-react';
import { cn } from '@/lib/cn';
import { animateOnce } from './utils';
export type TabItem = { id: string; label: string; icon?: ReactNode };
export type TabsProps = {
tabs: TabItem[];
value: string;
onChange: (id: string) => void;
variant?: 'pill' | 'underline';
className?: string;
};
export function Tabs({ tabs, value, onChange, variant = 'pill', className }: TabsProps) {
const listRef = useRef<HTMLDivElement>(null);
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
const [ind, setInd] = useState({ left: 0, width: 0, ready: false });
useLayoutEffect(() => {
const measure = () => {
const el = refs.current[value];
if (el) setInd((p) => ({ left: el.offsetLeft, width: el.offsetWidth, ready: p.ready || el.offsetWidth > 0 }));
};
measure();
const ro = new ResizeObserver(measure);
if (listRef.current) ro.observe(listRef.current);
return () => ro.disconnect();
}, [value, tabs]);
const onKey = (e: KeyboardEvent) => {
const i = tabs.findIndex((t) => t.id === value);
let next = -1;
if (e.key === 'ArrowRight') next = (i + 1) % tabs.length;
if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length;
if (e.key === 'Home') next = 0;
if (e.key === 'End') next = tabs.length - 1;
if (next < 0) return;
e.preventDefault();
onChange(tabs[next].id);
refs.current[tabs[next].id]?.focus();
};
const pill = variant === 'pill';
return (
<div
ref={listRef}
role="tablist"
onKeyDown={onKey}
className={cn(
'relative inline-flex items-center',
pill ? 'gap-0.5 rounded-xl bg-ink-900 p-1 shadow-hairline' : 'gap-1 border-b border-white/5',
className,
)}
>
<span
aria-hidden
className={cn(
'absolute',
pill ? 'inset-y-1 rounded-lg bg-ink-800 shadow-[0_0_0_1px_rgba(255,255,255,0.08),0_2px_8px_rgba(0,0,0,0.4)]' : '-bottom-px h-0.5 rounded-full bg-brand-400',
ind.ready && 'transition-[left,width] duration-500 ease-spring',
)}
style={{ left: ind.left, width: ind.width, opacity: ind.ready ? 1 : 0 }}
/>
{tabs.map((t) => {
const active = t.id === value;
return (
<button
key={t.id}
ref={(el) => (refs.current[t.id] = el)}
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(t.id)}
className={cn(
'focus-ring relative z-10 inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-colors duration-200 active:scale-[0.97] [&_svg]:h-4 [&_svg]:w-4',
pill ? 'h-8 rounded-lg px-3.5' : 'h-10 rounded-md px-3',
active ? 'text-ink-50' : 'text-ink-400 hover:text-ink-200',
)}
>
{t.icon && <span className={cn('transition-colors', active && !pill && 'text-brand-300')}>{t.icon}</span>}
{t.label}
</button>
);
})}
</div>
);
}
const SETTINGS = [
{ id: 'general', label: 'General', icon: <Settings /> },
{ id: 'team', label: 'Team', icon: <Users /> },
{ id: 'billing', label: 'Billing', icon: <CreditCard /> },
{ id: 'alerts', label: 'Alerts', icon: <Bell /> },
];
const PANELS: Record<string, { title: string; body: string; rows: [string, string][] }> = {
general: { title: 'Workspace', body: 'Name, URL and default region.', rows: [['Name', 'Acme Inc.'], ['URL', 'acme.lumen.dev'], ['Region', 'Frankfurt (eu-central-1)']] },
team: { title: 'Members', body: '8 people have access to this workspace.', rows: [['Owners', '2'], ['Admins', '3'], ['Members', '3']] },
billing: { title: 'Plan', body: 'Pro · renews on Mar 14, 2025.', rows: [['Seats', '8 × $24'], ['Usage', '$38.20'], ['Total', '$230.20 / mo']] },
alerts: { title: 'Notifications', body: 'Where we reach you about deployments.', rows: [['Email', 'On'], ['Slack', '#deploys'], ['SMS', 'Off']] },
};
export function TabsDemo() {
const [tab, setTab] = useState('general');
const [range, setRange] = useState('7d');
const order = SETTINGS.map((s) => s.id);
const [dir, setDir] = useState(1);
const panel = PANELS[tab];
const change = (id: string) => {
setDir(order.indexOf(id) >= order.indexOf(tab) ? 1 : -1);
setTab(id);
};
return (
<div className="mx-auto flex w-full max-w-md flex-col items-center gap-8">
<Tabs
variant="underline"
value={range}
onChange={setRange}
tabs={[
{ id: '24h', label: 'Last 24h' },
{ id: '7d', label: '7 days' },
{ id: '30d', label: '30 days' },
{ id: '90d', label: 'Quarter' },
]}
/>
<div className="w-full">
<div className="flex justify-center">
<Tabs tabs={SETTINGS} value={tab} onChange={change} />
</div>
<div className="mt-4 overflow-hidden rounded-2xl bg-ink-900 shadow-card">
<div
key={tab}
className="p-5"
ref={(el) =>
animateOnce(
el,
[
{ opacity: 0, transform: `translateX(${dir * 24}px)`, filter: 'blur(4px)' },
{ opacity: 1, transform: 'translateX(0)', filter: 'blur(0)' },
],
{ duration: 420, easing: 'cubic-bezier(0.16,1,0.3,1)' },
)
}
>
<p className="text-sm font-medium text-ink-50">{panel.title}</p>
<p className="mt-0.5 text-sm text-ink-400">{panel.body}</p>
<dl className="mt-4 divide-y divide-white/5">
{panel.rows.map(([k, v]) => (
<div key={k} className="flex justify-between py-2.5 text-sm">
<dt className="text-ink-400">{k}</dt>
<dd className="font-medium text-ink-100">{v}</dd>
</div>
))}
</dl>
</div>
</div>
</div>
</div>
);
}components/niceui/ui/core/utils.ts
/**
* Plays a Web Animation the first time an element mounts. Safe to call from
* inline ref callbacks (which React re-invokes on every render).
*/
export function animateOnce(el: HTMLElement | null, keyframes: Keyframe[], options: KeyframeAnimationOptions) {
if (!el || el.dataset.played) return;
el.dataset.played = '1';
el.animate(keyframes, options);
}
export const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));