Navigation and workspace
Feature tabs
Tabs over one shared panel, where the underline slides to the chosen tab and can fill as an autoplay timer, and the panel eases to the new height while the content slides in from the side you moved to.
Live preview
Loading preview
Four tabs, Plan, Build, Review and Ship, each showing a short heading, a sentence and a small preview card of a different height. Autoplay is 5000ms. Replay chooses Plan and lets autoplay run through Build, then scripts a click on Ship (a jump of two tabs) and a click back on Review.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add feature-tabsUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { type FeatureTab, FeatureTabs } from './feature-tabs';
function Pane({ title, text, children }: { title: string; text: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="m-0 text-md font-semibold text-primary">{title}</h3>
<p className="m-0 text-sm text-tertiary">{text}</p>
</div>
<div className="flex flex-col gap-3 rounded-xl border border-secondary bg-secondary p-4">
{children}
</div>
</div>
);
}
function Task({ done, children }: { done?: boolean; children: React.ReactNode }) {
return (
<div className={`flex items-center gap-2 text-sm ${done ? 'text-tertiary' : 'text-secondary'}`}>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="size-4 shrink-0">
<circle
cx="8"
cy="8"
r="6.5"
stroke={done ? 'var(--lunia-fg-success-primary)' : 'var(--lunia-fg-quaternary)'}
strokeWidth="1.5"
/>
{done ? (
<path
d="m5.25 8.25 1.9 1.9 3.6-4"
stroke="var(--lunia-fg-success-primary)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : null}
</svg>
{children}
</div>
);
}
function Bar({ label, value }: { label: string; value: number }) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-sm font-medium text-primary">
{label}
<span className="font-mono text-xs font-normal text-tertiary">{value}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-quaternary">
<div className="h-full rounded-full bg-brand-solid" style={{ width: `${value}%` }} />
</div>
</div>
);
}
const TABS: FeatureTab[] = [
{
id: 'plan',
label: 'Plan',
content: (
<Pane title="Plan the work" text="Break the goal into tasks and give each task an owner.">
<Task done>Write the brief</Task>
<Task>Agree on the scope</Task>
<Task>Set a release date</Task>
</Pane>
),
},
{
id: 'build',
label: 'Build',
content: (
<Pane title="Build it together" text="Follow each part as it moves from design to code.">
<Bar label="Design" value={80} />
<Bar label="Code" value={45} />
</Pane>
),
},
{
id: 'review',
label: 'Review',
content: (
<Pane title="Review every change" text="Read what changed and decide what stays.">
<Task done>Header spacing</Task>
<Task done>Button colors</Task>
<Task>Dark mode icons</Task>
<Task>Empty state copy</Task>
</Pane>
),
},
{
id: 'ship',
label: 'Ship',
content: (
<Pane title="Ship when it is ready" text="Release to everyone, or to a small group first.">
<div className="flex items-center justify-between text-sm font-medium text-primary">
Version 2.4
<span className="rounded-full bg-success-primary px-2 py-0.5 text-xs font-medium text-utility-green-700">
Live
</span>
</div>
</Pane>
),
},
];
// Replay starts on Plan and lets autoplay move to Build, then clicks Ship (a jump of two tabs) and back on Review.
const SCRIPT: [at: number, id: string][] = [
[6500, 'ship'],
[8500, 'review'],
];
export default function FeatureTabsDemo({ replay = 0 }: { replay?: number }) {
const [value, setValue] = React.useState('plan');
React.useEffect(() => {
if (replay === 0) return;
setValue('plan');
const timers = SCRIPT.map(([at, id]) => window.setTimeout(() => setValue(id), at));
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
return (
<div className="w-full max-w-[560px]">
<FeatureTabs tabs={TABS} value={value} onValueChange={setValue} autoplay={5000} label="Product steps" />
</div>
);
}
API
Parts: root, tab list, tab, track hairline (full width), indicator line (under the chosen tab), autoplay fill (inside the indicator), panel (height), pane (current), pane (leaving).
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | FeatureTab[] | - | Tabs in order: { id, label, content }. Required. |
| value | string | - | Id of the chosen tab when controlled. |
| defaultValue | string | first tab id | Id of the tab chosen at first when not controlled. |
| autoplay | number | - | Time in ms before the next tab is chosen; the indicator fills over this time. Pointing at the component or focusing inside it pauses the timer. Off when not set. |
| Event | Description |
|---|---|
| valueChange | Fires with the id of the newly chosen tab, from a click, a key or autoplay. |
| Slot | Description |
|---|---|
| content | Per tab: what the panel shows. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| FT1 | hover a tab, or a tab is chosen | tab label | color | text-tertiary → text-primary | duration-lg (320)ease-out | Swaps at once |
| FT2 | a tab is chosen | indicator line | transform translateX | old tab left → new tab left | duration-4xl (520)ease-throw | Moves at once |
| FT3 | a tab is chosen | indicator line | width | old tab width → new tab width | duration-2xl (420)ease-out | Resizes at once |
| FT4 | autoplay is set and a tab is chosen; pauses while pointed at or focused | autoplay fill | transform scaleX (from the left); when full, the next tab is chosen | 0 → 1 | prop:autoplay (5000)ease-linear | No loop |
| FT5 | a tab is chosen | panel | height | old pane height → new pane height | duration-3xl (460)ease-out | Resizes at once |
| FT6 | a tab is chosen | pane (current) | opacity + transform translateX + filter blur | 0, distance-slide x direction, 6px → 1, 0, 0 | duration-3xl (460)ease-outdelay 60ms | Shows at once |
| FT7 | a tab is chosen | pane (leaving) | opacity + transform translateX + filter blur | 1, 0, 0 → 0, -distance-slide x direction, 6px | duration-md (260)ease-out | Hides at once |
Accessibility
- A real tablist with an accessible name; each tab has role tab, aria-selected and aria-controls, and the panel has role tabpanel labelled by the chosen tab.
- Roving tab index: only the chosen tab is in the tab order; Left and Right arrows choose the previous or next tab (wrapping), Home and End choose the first or last.
- The leaving pane is aria-hidden and inert, so only the current content is read and reachable.
- Autoplay pauses while the pointer is over the component or focus is inside it, so content does not change while someone reads or uses it.
- The indicator, its track and the autoplay fill are aria-hidden.
- Visible focus ring on each tab.
- Reduced motion: autoplay is off, the indicator and panel height change at once, and the new content shows at once with no slide or blur.