niceui

Morphing Tabs

A segmented control whose indicator stretches toward the new tab before settling, while the panels crossfade.

ReactTailwind CSSTypeScriptBlocksMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/blocks-morphing-tabs.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/blocks/MorphingTabs.tsx
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowUpRight, TrendingDown, TrendingUp } from 'lucide-react';
import { cn } from '@/lib/cn';

export type MorphTab = { id: string; label: string; content: ReactNode };

export type MorphingTabsProps = { tabs: MorphTab[]; defaultValue?: string; onChange?: (id: string) => void };

export function MorphingTabs({ tabs, defaultValue, onChange }: MorphingTabsProps) {
  const [value, setValue] = useState(defaultValue ?? tabs[0].id);
  const [dir, setDir] = useState<1 | -1>(1);
  const [box, setBox] = useState({ left: 0, right: 0, ready: false });
  const listRef = useRef<HTMLDivElement>(null);
  const refs = useRef<Record<string, HTMLButtonElement | null>>({});
  const idx = tabs.findIndex((t) => t.id === value);

  useLayoutEffect(() => {
    const measureNow = () => {
      const el = refs.current[value];
      const list = listRef.current;
      if (!el || !list) return;
      setBox({ left: el.offsetLeft, right: list.clientWidth - el.offsetLeft - el.offsetWidth, ready: true });
    };
    measureNow();
    window.addEventListener('resize', measureNow);
    return () => window.removeEventListener('resize', measureNow);
  }, [value]);

  const select = (id: string) => {
    const next = tabs.findIndex((t) => t.id === id);
    setDir(next > idx ? 1 : -1);
    setValue(id);
    onChange?.(id);
  };

  const lead = 'cubic-bezier(0.34,1.3,0.64,1) 380ms';
  const trail = 'cubic-bezier(0.34,1.2,0.64,1) 420ms 90ms';

  return (
    <div className="w-full">
      <div ref={listRef} role="tablist" className="relative flex rounded-xl bg-ink-950/60 p-1 shadow-hairline">
        <span
          className={cn('absolute inset-y-1 rounded-lg bg-ink-800 shadow-[0_0_0_1px_rgba(255,255,255,0.08),0_4px_12px_-2px_rgba(0,0,0,0.5)]', !box.ready && 'opacity-0')}
          style={{
            left: box.left,
            right: box.right,
            transition: dir === 1 ? `right ${lead}, left ${trail}` : `left ${lead}, right ${trail}`,
          }}
        />
        {tabs.map((t) => (
          <button
            key={t.id}
            ref={(el) => (refs.current[t.id] = el)}
            role="tab"
            aria-selected={value === t.id}
            onClick={() => select(t.id)}
            className={cn('focus-ring relative z-10 flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors duration-300', value === t.id ? 'text-ink-50' : 'text-ink-400 hover:text-ink-200')}
          >
            {t.label}
          </button>
        ))}
      </div>
      <div className="mt-4 grid">
        {tabs.map((t, i) => {
          const on = t.id === value;
          const offset = i < idx ? -16 : 16;
          return (
            <div
              key={t.id}
              role="tabpanel"
              aria-hidden={!on}
              className={cn('[grid-area:1/1] transition-all duration-500 ease-smooth', on ? 'opacity-100 blur-0' : 'pointer-events-none opacity-0 blur-sm')}
              style={{ transform: on ? 'none' : `translateX(${offset}px)`, transitionDelay: on ? '80ms' : '0ms' }}
            >
              {t.content}
            </div>
          );
        })}
      </div>
    </div>
  );
}

function Stat({ label, value, delta }: { label: string; value: string; delta: number }) {
  const up = delta >= 0;
  return (
    <div className="rounded-xl bg-ink-850 p-3.5 shadow-hairline">
      <p className="text-xs text-ink-400">{label}</p>
      <p className="mt-1 text-xl font-medium tabular-nums text-ink-50">{value}</p>
      <p className={cn('mt-1 flex items-center gap-1 text-xs', up ? 'text-emerald-400' : 'text-rose-400')}>
        {up ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
        {up ? '+' : ''}{delta}% vs last period
      </p>
    </div>
  );
}

const PERIODS = [
  { id: 'day', label: 'Today', stats: [['Revenue', '$4,281', 12.4], ['Visitors', '18,204', 4.1], ['Conversion', '3.8%', -0.6]] },
  { id: 'week', label: 'Week', stats: [['Revenue', '$28,940', 8.2], ['Visitors', '121,880', 11.9], ['Conversion', '4.1%', 1.2]] },
  { id: 'month', label: 'Month', stats: [['Revenue', '$118,302', 21.7], ['Visitors', '502,114', 18.3], ['Conversion', '4.4%', 2.4]] },
  { id: 'year', label: 'Year', stats: [['Revenue', '$1.24M', 64.0], ['Visitors', '5.9M', 48.2], ['Conversion', '3.9%', -1.1]] },
] as const;

export function MorphingTabsDemo() {
  return (
    <div className="mx-auto w-full max-w-lg rounded-2xl bg-ink-900 p-4 shadow-card">
      <div className="mb-4 flex items-center justify-between px-1">
        <p className="text-sm font-medium text-ink-50">Overview</p>
        <a href="#" onClick={(e) => e.preventDefault()} className="flex items-center gap-1 text-xs text-ink-400 transition-colors hover:text-ink-100">
          Full report <ArrowUpRight className="h-3 w-3" />
        </a>
      </div>
      <MorphingTabs
        defaultValue="week"
        tabs={PERIODS.map((p) => ({
          id: p.id,
          label: p.label,
          content: (
            <div className="grid grid-cols-3 gap-2">
              {p.stats.map(([l, v, d]) => <Stat key={l} label={l} value={v} delta={d} />)}
            </div>
          ),
        }))}
      />
    </div>
  );
}