Auto Feature Tabs New
Auto-advancing feature tabs with a glowing progress underline; each tab swaps in its own live content.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-feature-tabs.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/AgentWorkflow.tsx
import { type ComponentType, type ReactNode, useEffect, useState } from 'react';
import { Bot, RotateCcw, Search, Terminal } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useInView } from '@/components/niceui/pages/landing/shared/motion';
/* ─── AgentTerminal ────────────────────────────────────────────────── */
export type AgentTerminalProps = {
command: string;
lines: string[];
/** Milliseconds between lines. */
interval?: number;
label?: string;
className?: string;
};
/** A terminal that types out an agent's run log line by line once it scrolls into view. */
export function AgentTerminal({ command, lines, interval = 650, label = 'agent', className }: AgentTerminalProps) {
const { ref, inView } = useInView<HTMLDivElement>(0.3);
const [n, setN] = useState(0);
useEffect(() => {
if (!inView || n >= lines.length) return;
const t = setTimeout(() => setN((x) => x + 1), interval);
return () => clearTimeout(t);
}, [inView, n, lines.length, interval]);
const done = n >= lines.length;
return (
<div ref={ref} className={cn('w-full overflow-hidden rounded-xl border border-white/10 bg-ink-900/80 shadow-pop', className)}>
<div className="flex items-center gap-1.5 border-b border-white/[.07] px-3 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-white/15" />
<span className="h-2.5 w-2.5 rounded-full bg-white/15" />
<span className="h-2.5 w-2.5 rounded-full bg-white/15" />
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full bg-brand-300/10 px-2 py-0.5 text-[10px] text-brand-300">
<Terminal className="h-3 w-3" /> {label}
</span>
{done && (
<button onClick={() => setN(0)} aria-label="Replay" className="rounded-md p-1 text-ink-500 transition hover:bg-white/5 hover:text-ink-100">
<RotateCcw className="h-3 w-3" />
</button>
)}
</div>
<div className="min-h-[160px] space-y-1.5 p-4 text-left font-mono text-[12px] leading-relaxed">
<p className="text-ink-200">
<span className="text-brand-300">$</span> {command}
</p>
{lines.slice(0, n).map((l, i) => (
<p key={i} className={cn('animate-fade-in', i === lines.length - 1 ? 'text-brand-300' : 'text-ink-400')}>
{l}
</p>
))}
{!done && <span className="inline-block h-3.5 w-2 animate-blink bg-brand-300" />}
</div>
</div>
);
}
/* ─── DiffSuggestion ───────────────────────────────────────────────── */
export type DiffSuggestionProps = {
file: string;
before: string[];
after: string[];
/** Context lines shown above and below the change. */
head?: string;
tail?: string;
issue: string;
onApply?: (applied: boolean) => void;
className?: string;
};
/** An inline code-review suggestion that swaps removed lines for added lines when applied. */
export function DiffSuggestion({ file, before, after, head, tail, issue, onApply, className }: DiffSuggestionProps) {
const [applied, setApplied] = useState(false);
const toggle = () => {
setApplied((a) => !a);
onApply?.(!applied);
};
return (
<div className={cn('w-full overflow-hidden rounded-xl border border-white/10 bg-ink-900/80 text-left shadow-pop', className)}>
<div className="flex items-center justify-between border-b border-white/[.07] px-4 py-2.5 font-mono text-[11px] text-ink-500">
{file}
<span className="text-ink-400">
<span className="text-emerald-300">+{after.length}</span> <span className="text-rose-300">−{before.length}</span>
</span>
</div>
<div className="p-3 font-mono text-[11.5px] leading-6">
{head && <p className="px-2 text-ink-400">{head}</p>}
{(applied ? after : before).map((l, i) => (
<p
key={(applied ? 'a' : 'b') + i}
className={cn('animate-fade-in px-2', applied ? 'bg-emerald-500/10 text-emerald-300' : 'bg-rose-500/10 text-rose-300/90 line-through decoration-rose-300/40')}
>
{applied ? '+ ' : '- '}
{l}
</p>
))}
{tail && <p className="px-2 text-ink-400">{tail}</p>}
</div>
<div className="flex items-center justify-between gap-3 border-t border-white/[.07] px-4 py-3">
<span className="text-[12px] text-ink-400">{applied ? 'Suggestion applied' : issue}</span>
<button
onClick={toggle}
className={cn(
'shrink-0 rounded-full px-3 py-1.5 text-[12px] font-medium transition',
applied ? 'border border-white/15 text-ink-300 hover:border-white/30' : 'bg-brand-300 text-brand-950 shadow-glow hover:bg-brand-200',
)}
>
{applied ? 'Undo' : 'Apply suggestion'}
</button>
</div>
</div>
);
}
/* ─── McpToolPicker ────────────────────────────────────────────────── */
export type McpTool = { name: string; installed?: boolean; hint?: string };
export type McpToolPickerProps = {
tools: McpTool[];
placeholder?: string;
onChange?: (tools: McpTool[]) => void;
className?: string;
};
/** A searchable list of MCP servers with one-click install toggles. */
export function McpToolPicker({ tools: initial, placeholder = 'Search MCP servers…', onChange, className }: McpToolPickerProps) {
const [tools, setTools] = useState(initial);
const [q, setQ] = useState('');
const shown = tools.filter((t) => t.name.toLowerCase().includes(q.toLowerCase()));
const toggle = (name: string) => {
const next = tools.map((t) => (t.name === name ? { ...t, installed: !t.installed } : t));
setTools(next);
onChange?.(next);
};
return (
<div className={cn('w-full overflow-hidden rounded-xl border border-white/10 bg-ink-900/80 text-left shadow-pop', className)}>
<div className="flex items-center gap-2 border-b border-white/[.07] px-3.5 py-3 text-ink-500">
<Search className="h-3.5 w-3.5" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="min-w-0 flex-1 bg-transparent text-[13px] text-ink-100 outline-none placeholder:text-ink-500" />
<span className="font-mono text-[10.5px] text-ink-500">{tools.filter((t) => t.installed).length} on</span>
</div>
<ul className="p-1.5">
{shown.length === 0 && <li className="px-3 py-6 text-center text-[13px] text-ink-500">No servers match “{q}”</li>}
{shown.map((t) => (
<li key={t.name} className="flex items-center justify-between rounded-lg px-2.5 py-2 text-[13px] text-ink-200 transition-colors hover:bg-white/[.03]">
<span className="flex min-w-0 items-center gap-2.5">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-md border border-white/10 font-mono text-[10px] text-ink-400">{t.name[0]}</span>
<span className="truncate">{t.name}</span>
{t.hint && <span className="hidden truncate text-[11.5px] text-ink-500 sm:inline">{t.hint}</span>}
</span>
<button
onClick={() => toggle(t.name)}
className={cn(
'shrink-0 rounded-full px-2.5 py-0.5 text-[11px] transition',
t.installed ? 'bg-brand-300/10 text-brand-300 ring-1 ring-brand-300/30' : 'text-ink-400 ring-1 ring-white/15 hover:text-ink-100',
)}
>
{t.installed ? 'Installed' : 'Install'}
</button>
</li>
))}
</ul>
</div>
);
}
/* ─── ParallelAgents ───────────────────────────────────────────────── */
export type AgentRun = { who: string; task: string; progress: number; tokens?: string };
/** Agents running side by side on a shared timeline, progress bars fill when in view. */
export function ParallelAgents({ agents, className }: { agents: AgentRun[]; className?: string }) {
const { ref, inView } = useInView<HTMLDivElement>(0.3);
return (
<div ref={ref} className={cn('relative w-full space-y-3 text-left', className)}>
<span aria-hidden className="absolute bottom-6 left-[22px] top-6 w-px bg-gradient-to-b from-brand-300/60 via-brand-300/20 to-transparent" />
{agents.map((a, i) => (
<div
key={a.who}
className={cn('relative ml-10 rounded-xl border border-white/10 bg-ink-900/80 p-3.5 shadow-pop transition-all duration-700 ease-smooth', inView ? 'translate-x-0 opacity-100' : 'translate-x-3 opacity-0')}
style={{ transitionDelay: `${i * 140}ms` }}
>
<span aria-hidden className="absolute -left-[26px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full bg-brand-300 shadow-[0_0_10px_#7dd3fc]" />
<div className="flex items-center justify-between gap-3 font-mono text-[11.5px]">
<span className="flex min-w-0 items-center gap-1.5 truncate text-ink-100">
<Bot className="h-3.5 w-3.5 shrink-0 text-brand-300" /> {a.who}
</span>
{a.tokens && <span className="shrink-0 text-ink-500">{a.tokens} tokens</span>}
</div>
<p className="mt-1.5 text-[12px] text-ink-400">{a.task}…</p>
<div className="mt-2.5 h-1 overflow-hidden rounded-full bg-white/[.06]">
<div className="h-full rounded-full bg-brand-300 transition-[width] duration-[1600ms] ease-smooth" style={{ width: inView ? `${a.progress}%` : '0%', transitionDelay: `${300 + i * 140}ms` }} />
</div>
</div>
))}
</div>
);
}
/* ─── FeatureTabs ──────────────────────────────────────────────────── */
export type FeatureTab = { label: string; icon?: ComponentType<{ className?: string }>; title: string; body: string; content: ReactNode };
/** Auto-advancing feature tabs with a progress underline; click any tab to jump. */
export function FeatureTabs({ tabs, duration = 6000, className }: { tabs: FeatureTab[]; duration?: number; className?: string }) {
const [active, setActive] = useState(0);
const [pct, setPct] = useState(0);
useEffect(() => {
const start = performance.now();
let raf = 0;
const loop = (now: number) => {
const p = Math.min(100, ((now - start) / duration) * 100);
setPct(p);
if (p >= 100) setActive((a) => (a + 1) % tabs.length);
else raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [active, duration, tabs.length]);
const tab = tabs[active];
return (
<div className={cn('w-full', className)}>
<div className="grid grid-cols-2 border-b border-white/[.07] md:grid-cols-4">
{tabs.map((t, i) => {
const Icon = t.icon;
return (
<button
key={t.label}
onClick={() => setActive(i)}
className={cn(
'relative flex items-center justify-center gap-2 border-white/[.07] py-4 text-[13px] transition-colors [&:not(:last-child)]:border-r',
i === active ? 'text-ink-50' : 'text-ink-500 hover:text-ink-300',
)}
>
{Icon && <Icon className={cn('h-3.5 w-3.5', i === active && 'text-brand-300')} />}
{t.label}
<span className="absolute inset-x-0 bottom-0 h-px bg-white/[.04]">
{i === active && <span className="block h-full bg-brand-300 shadow-[0_0_8px_#7dd3fc]" style={{ width: `${pct}%` }} />}
</span>
</button>
);
})}
</div>
<div className="grid items-center gap-10 px-6 py-12 md:grid-cols-[1fr_1.4fr] md:px-10">
<div key={tab.label} className="animate-fade-up text-left">
<h3 className="text-2xl font-medium tracking-[-0.03em] text-ink-50">{tab.title}</h3>
<p className="mt-3 max-w-sm text-[15px] text-ink-400">{tab.body}</p>
</div>
<div key={tab.label + '-content'} className="animate-fade-up">
{tab.content}
</div>
</div>
</div>
);
}
/* ─── NetworkPulse ─────────────────────────────────────────────────── */
/** A hub-and-spoke constellation with pulses travelling from the core to each node. */
export function NetworkPulse({ nodes = 9, className }: { nodes?: number; className?: string }) {
const points = Array.from({ length: nodes }, (_, i) => {
const a = (i / nodes) * Math.PI * 2 + 0.3;
const r = i % 2 ? 118 : 160;
return { x: 200 + Math.cos(a) * r, y: 170 + Math.sin(a) * r * 0.78, d: i * 0.35 };
});
return (
<svg viewBox="0 0 400 340" className={cn('w-full max-w-md', className)} aria-hidden>
<defs>
<radialGradient id="np-core">
<stop offset="0" stopColor="#7dd3fc" stopOpacity=".55" />
<stop offset="1" stopColor="#7dd3fc" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="200" cy="170" r="90" fill="url(#np-core)" />
{points.map((n, i) => (
<g key={i}>
<line x1="200" y1="170" x2={n.x} y2={n.y} stroke="rgba(255,255,255,.12)" />
<circle r="2" fill="#7dd3fc">
<animateMotion dur={`${2.4 + (i % 3) * 0.6}s`} begin={`${n.d}s`} repeatCount="indefinite" path={`M200,170 L${n.x},${n.y}`} />
</circle>
<circle cx={n.x} cy={n.y} r="5.5" fill="#0b0c0e" stroke="rgba(125,211,252,.7)">
<animate attributeName="r" values="5.5;7;5.5" dur="3s" begin={`${n.d}s`} repeatCount="indefinite" />
</circle>
</g>
))}
<circle cx="200" cy="170" r="11" fill="#0b0c0e" stroke="#7dd3fc" strokeWidth="1.5" />
<circle cx="200" cy="170" r="3.5" fill="#7dd3fc" />
</svg>
);
}
/* ─── demos (used by the docs registry) ────────────────────────────── */
export const terminalDemoProps: AgentTerminalProps = {
command: 'northstar run NS-482',
lines: ['Reading ticket NS-482 …', 'Planning 3 components', 'Writing PricingCard.tsx', 'Running visual tests · 12/12', '✓ Opened PR #318 — ready for review'],
};
export const diffDemoProps: DiffSuggestionProps = {
file: 'useCart.ts',
head: 'export function useCart() {',
tail: '}',
before: ['const [items, setItems] = useState([])', 'useEffect(() => save(items))'],
after: ['const [items, setItems] = useState<Item[]>(load)', 'useEffect(() => save(items), [items])'],
issue: 'Missing dependency array',
};
export const toolDemo: McpTool[] = [
{ name: 'Figma', installed: true, hint: 'Design files' },
{ name: 'GitHub', installed: true, hint: 'Repos & PRs' },
{ name: 'Linear', hint: 'Issues' },
{ name: 'Storybook', installed: true, hint: 'Stories' },
{ name: 'Sentry', hint: 'Errors' },
];
export const agentsDemo: AgentRun[] = [
{ who: 'designer@team', task: 'Drafting variants', progress: 82, tokens: '18k' },
{ who: 'builder@team', task: 'Writing components', progress: 54, tokens: '31k' },
{ who: 'tester@team', task: 'Capturing screenshots', progress: 27, tokens: '6k' },
];
export const AgentTerminalDemo = () => (
<div className="w-full max-w-md">
<AgentTerminal {...terminalDemoProps} />
</div>
);
export const DiffSuggestionDemo = () => (
<div className="w-full max-w-md">
<DiffSuggestion {...diffDemoProps} />
</div>
);
export const McpToolPickerDemo = () => (
<div className="w-full max-w-sm">
<McpToolPicker tools={toolDemo} />
</div>
);
export const ParallelAgentsDemo = () => (
<div className="w-full max-w-md">
<ParallelAgents agents={agentsDemo} />
</div>
);
export const NetworkPulseDemo = () => <NetworkPulse />;
export const FeatureTabsDemo = () => (
<div className="w-full overflow-hidden rounded-2xl border border-white/[.07] bg-ink-950">
<FeatureTabs
duration={5000}
tabs={[
{ label: 'Generate', title: 'Components from a sentence', body: 'Describe the UI and get typed React with stories.', content: <AgentTerminal {...terminalDemoProps} /> },
{ label: 'Review', title: 'Reviews that read the diff', body: 'Line-level suggestions you can apply in one click.', content: <DiffSuggestion {...diffDemoProps} /> },
{ label: 'Integrate', title: 'Plug in every tool', body: 'Hundreds of MCP servers, one toggle away.', content: <McpToolPicker tools={toolDemo} /> },
{ label: 'Scale', title: 'Many agents, one queue', body: 'Parallel runs with live progress and token use.', content: <ParallelAgents agents={agentsDemo} /> },
]}
/>
</div>
);components/niceui/pages/landing/shared/motion.tsx
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
export function useInView<T extends Element>(threshold = 0.2) {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
io.disconnect();
}
},
{ threshold },
);
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return { ref, inView };
}
export function Reveal({ children, delay = 0, className }: { children: ReactNode; delay?: number; className?: string }) {
const { ref, inView } = useInView<HTMLDivElement>(0.15);
return (
<div
ref={ref}
className={cn('transition-all duration-1000 ease-smooth', inView ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-6 opacity-0 blur-[2px]', className)}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
export function useCountUp(target: number, active: boolean, duration = 1800) {
const [val, setVal] = useState(0);
useEffect(() => {
if (!active) return;
let raf = 0;
const start = performance.now();
const loop = (now: number) => {
const t = Math.min(1, (now - start) / duration);
setVal(target * (1 - Math.pow(2, -10 * t)));
if (t < 1) raf = requestAnimationFrame(loop);
else setVal(target);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [target, active, duration]);
return val;
}