Lunia DS

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.

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-tabs

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
tabsFeatureTab[]-Tabs in order: { id, label, content }. Required.
valuestring-Id of the chosen tab when controlled.
defaultValuestringfirst tab idId of the tab chosen at first when not controlled.
autoplaynumber-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.
EventDescription
valueChangeFires with the id of the newly chosen tab, from a click, a key or autoplay.
SlotDescription
contentPer 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
FT1hover a tab, or a tab is chosentab labelcolortext-tertiary → text-primaryduration-lg (320)ease-outSwaps at once
FT2a tab is chosenindicator linetransform translateXold tab left → new tab leftduration-4xl (520)ease-throwMoves at once
FT3a tab is chosenindicator linewidthold tab width → new tab widthduration-2xl (420)ease-outResizes at once
FT4autoplay is set and a tab is chosen; pauses while pointed at or focusedautoplay filltransform scaleX (from the left); when full, the next tab is chosen0 → 1prop:autoplay (5000)ease-linearNo loop
FT5a tab is chosenpanelheightold pane height → new pane heightduration-3xl (460)ease-outResizes at once
FT6a tab is chosenpane (current)opacity + transform translateX + filter blur0, distance-slide x direction, 6px → 1, 0, 0duration-3xl (460)ease-outdelay 60msShows at once
FT7a tab is chosenpane (leaving)opacity + transform translateX + filter blur1, 0, 0 → 0, -distance-slide x direction, 6pxduration-md (260)ease-outHides 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.