Icon Tabs New
Inspector tabs where the active tab is a raised white tab joined to the panel below it. Each tab here holds real controls.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/design-icon-tabs.json02
Or copy the source below (5 files). Dependencies: lucide-react.
Source
components/niceui/design/IconTabs.tsx
import { useState, type ReactNode } from 'react';
import { Palette, Shapes, Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
import { LightFrame, focusRing } from './shared';
import { SegmentedPills } from './SegmentedPills';
import { InlineSlider } from './InlineSlider';
import { ToggleRow } from './ToggleRow';
export type IconTab = { id: string; label: string; icon: ReactNode; content: ReactNode };
/** Panel tabs where the active tab is a raised white tab joined to the panel below. */
export function IconTabs({ tabs, defaultTab }: { tabs: IconTab[]; defaultTab?: string }) {
const [active, setActive] = useState(defaultTab ?? tabs[0].id);
const current = tabs.find((t) => t.id === active) ?? tabs[0];
return (
<div>
<div role="tablist" className="flex gap-1 px-1">
{tabs.map((t, i) => {
const on = t.id === active;
return (
<button
key={t.id}
role="tab"
aria-selected={on}
tabIndex={on ? 0 : -1}
onClick={() => setActive(t.id)}
onKeyDown={(e) => {
const dir = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (dir) setActive(tabs[(i + dir + tabs.length) % tabs.length].id);
}}
className={cn(
'relative flex items-center gap-1.5 rounded-t-xl px-3.5 pb-2.5 pt-2 text-[13px] font-medium transition-colors',
focusRing,
on ? 'z-10 -mb-px border border-b-0 border-zinc-200/80 bg-white text-zinc-900' : 'text-zinc-500 hover:text-zinc-800',
)}
>
<span className={cn(on ? 'text-zinc-800' : 'text-zinc-400')}>{t.icon}</span>
{t.label}
</button>
);
})}
</div>
<div role="tabpanel" className="rounded-2xl rounded-tl-none border border-zinc-200/80 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div key={current.id} className="animate-fade-in">{current.content}</div>
</div>
</div>
);
}
function ShapeTab() {
const [mode, setMode] = useState('fill');
const [w, setW] = useState(2);
return (
<div className="space-y-3">
<SegmentedPills full size="sm" label="Shape mode" value={mode} onChange={setMode} options={[{ value: 'fill', label: 'Fill' }, { value: 'stroke', label: 'Stroke' }, { value: 'both', label: 'Both' }]} />
<p className="text-[12px] leading-relaxed text-zinc-500">The traced outline becomes a vector shape you can recolour, stroke and export as SVG.</p>
<InlineSlider label="Stroke width" value={w} onChange={setW} max={12} step={0.5} unit="px" defaultValue={2} />
</div>
);
}
function StyleTab() {
const colors = ['#0b0b0c', '#2563eb', '#f97316', '#10b981', '#e11d48', '#a855f7'];
const [c, setC] = useState(colors[1]);
const [grain, setGrain] = useState(false);
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{colors.map((x) => (
<button key={x} aria-label={`Colour ${x}`} aria-pressed={c === x} onClick={() => setC(x)} className={cn('h-8 w-8 rounded-full ring-2 ring-offset-2 transition-all', focusRing, c === x ? 'ring-zinc-900' : 'ring-transparent hover:ring-zinc-300')} style={{ background: x }} />
))}
</div>
<ToggleRow label="Add grain" checked={grain} onChange={setGrain} />
</div>
);
}
function EffectsTab() {
const [blur, setBlur] = useState(12);
const [y, setY] = useState(8);
return (
<div className="space-y-2.5">
<p className="text-[12px] font-medium text-zinc-500">Drop shadow</p>
<InlineSlider label="Blur" value={blur} onChange={setBlur} max={64} step={1} unit="px" decimals={0} defaultValue={12} />
<InlineSlider label="Offset Y" value={y} onChange={setY} max={32} step={1} unit="px" decimals={0} defaultValue={8} />
</div>
);
}
export function IconTabsDemo() {
return (
<LightFrame bare className="mx-auto max-w-[400px] p-4 sm:p-5">
<IconTabs
tabs={[
{ id: 'shape', label: 'Shape', icon: <Shapes className="h-3.5 w-3.5" />, content: <ShapeTab /> },
{ id: 'style', label: 'Style', icon: <Palette className="h-3.5 w-3.5" />, content: <StyleTab /> },
{ id: 'fx', label: 'Effects', icon: <Sparkles className="h-3.5 w-3.5" />, content: <EffectsTab /> },
]}
/>
</LightFrame>
);
}components/niceui/design/shared.tsx
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** A light surface for demos, since the doc stage itself is dark. */
export function LightFrame({ children, className, bare }: { children: ReactNode; className?: string; bare?: boolean }) {
return (
<div className={cn('w-full rounded-2xl font-sans', bare ? 'overflow-hidden bg-[#f4f4f5]' : 'bg-[#fafafa] p-4 sm:p-6', ' text-[13px] text-zinc-800 shadow-[0_1px_0_rgba(255,255,255,0.8)_inset,0_30px_60px_-30px_rgba(0,0,0,0.5)] ring-1 ring-zinc-200', className)}>
{children}
</div>
);
}
export function Panel({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('rounded-2xl border border-zinc-200/80 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]', className)}>{children}</div>;
}
export function SectionHead({ title, meta }: { title: string; meta?: string }) {
return (
<div className="mb-3 flex items-baseline justify-between gap-3">
<h4 className="text-[13px] font-semibold text-zinc-900">{title}</h4>
{meta && <span className="truncate text-[12px] text-zinc-500">{meta}</span>}
</div>
);
}
export const focusRing = 'outline-none focus-visible:ring-2 focus-visible:ring-zinc-900/20 focus-visible:ring-offset-1';components/niceui/design/SegmentedPills.tsx
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { AlignCenter, AlignLeft, AlignRight, Circle, Layers, Square, Triangle } from 'lucide-react';
import { cn } from '@/lib/cn';
import { LightFrame, Panel, focusRing } from './shared';
export type PillOption = { value: string; label?: string; icon?: ReactNode };
export type SegmentedPillsProps = {
options: PillOption[];
value: string;
onChange: (v: string) => void;
size?: 'sm' | 'md';
full?: boolean;
label?: string;
className?: string;
};
/** A soft track with a white pill that slides to the active option. */
export function SegmentedPills({ options, value, onChange, size = 'md', full, label, className }: SegmentedPillsProps) {
const ref = useRef<HTMLDivElement>(null);
const [pill, setPill] = useState({ left: 0, width: 0 });
useLayoutEffect(() => {
const el = ref.current?.querySelector<HTMLButtonElement>(`[data-v="${CSS.escape(value)}"]`);
if (el) setPill({ left: el.offsetLeft, width: el.offsetWidth });
}, [value, options.length, size, full]);
const move = (dir: number) => {
const i = options.findIndex((o) => o.value === value);
const next = options[(i + dir + options.length) % options.length];
onChange(next.value);
ref.current?.querySelector<HTMLButtonElement>(`[data-v="${CSS.escape(next.value)}"]`)?.focus();
};
return (
<div
ref={ref}
role="radiogroup"
aria-label={label}
className={cn('relative flex rounded-xl bg-zinc-100 p-[3px] ring-1 ring-inset ring-zinc-200/70', full ? 'w-full' : 'w-fit', className)}
>
<span className="pointer-events-none absolute inset-y-[3px] rounded-[9px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)] transition-all duration-300 ease-smooth" style={{ left: pill.left, width: pill.width }} />
{options.map((o) => {
const on = o.value === value;
return (
<button
key={o.value}
data-v={o.value}
role="radio"
aria-checked={on}
aria-label={o.label ? undefined : o.value}
tabIndex={on ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') (e.preventDefault(), move(1));
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') (e.preventDefault(), move(-1));
}}
className={cn(
'relative z-10 flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[9px] font-medium transition-colors',
focusRing,
size === 'sm' ? 'h-7 px-2 text-[12px] sm:px-2.5' : 'h-8 px-2 text-[13px] sm:px-3.5',
full && 'min-w-0 flex-1 overflow-hidden text-ellipsis',
on ? 'text-zinc-900' : 'text-zinc-500 hover:text-zinc-800',
)}
>
{o.icon}
{o.label}
</button>
);
})}
</div>
);
}
export function SegmentedPillsDemo() {
const [mode, setMode] = useState('trace');
const [source, setSource] = useState('auto');
const [align, setAlign] = useState('left');
const [shape, setShape] = useState('square');
return (
<LightFrame className="mx-auto max-w-[460px]">
<Panel className="space-y-5">
<div className="space-y-2">
<p className="text-[12px] font-medium text-zinc-500">Vectorize</p>
<SegmentedPills full label="Vectorize mode" value={mode} onChange={setMode} options={[{ value: 'trace', label: 'Trace' }, { value: 'posterize', label: 'Posterize' }, { value: 'outline', label: 'Outline' }]} />
</div>
<div className="space-y-2">
<p className="text-[12px] font-medium text-zinc-500">Detect shapes by</p>
<SegmentedPills full size="sm" label="Detect shapes by" value={source} onChange={setSource} options={['auto', 'dark', 'light', 'alpha', 'color'].map((v) => ({ value: v, label: v[0].toUpperCase() + v.slice(1) }))} />
</div>
<div className="flex flex-wrap items-center gap-3">
<SegmentedPills size="sm" label="Text alignment" value={align} onChange={setAlign} options={[{ value: 'left', icon: <AlignLeft className="h-3.5 w-3.5" /> }, { value: 'center', icon: <AlignCenter className="h-3.5 w-3.5" /> }, { value: 'right', icon: <AlignRight className="h-3.5 w-3.5" /> }]} />
<SegmentedPills size="sm" label="Shape" value={shape} onChange={setShape} options={[{ value: 'square', icon: <Square className="h-3.5 w-3.5" />, label: 'Rect' }, { value: 'circle', icon: <Circle className="h-3.5 w-3.5" />, label: 'Ellipse' }, { value: 'tri', icon: <Triangle className="h-3.5 w-3.5" />, label: 'Poly' }]} />
</div>
<p className="flex items-center gap-1.5 text-[12px] text-zinc-500">
<Layers className="h-3.5 w-3.5" /> Use ← → to move between options.
</p>
</Panel>
</LightFrame>
);
}components/niceui/design/InlineSlider.tsx
import { useRef, useState } from 'react';
import { RotateCcw } from 'lucide-react';
import { cn } from '@/lib/cn';
import { LightFrame, Panel, SectionHead, focusRing } from './shared';
export type InlineSliderProps = {
label: string;
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
unit?: string;
defaultValue?: number;
decimals?: number;
className?: string;
};
/** A slider whose label lives inside the track: drag anywhere to scrub, double-click the value to type. */
export function InlineSlider({ label, value, onChange, min = 0, max = 1, step = 0.01, unit, defaultValue, decimals = 1, className }: InlineSliderProps) {
const track = useRef<HTMLDivElement>(null);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [drag, setDrag] = useState(false);
const clamp = (v: number) => Math.min(max, Math.max(min, Math.round(v / step) * step));
const pct = ((value - min) / (max - min)) * 100;
const setFromX = (x: number) => {
const r = track.current!.getBoundingClientRect();
onChange(clamp(min + ((x - r.left) / r.width) * (max - min)));
};
const commit = () => {
const n = parseFloat(draft);
if (!Number.isNaN(n)) onChange(clamp(n));
setEditing(false);
};
const changed = defaultValue !== undefined && Math.abs(value - defaultValue) > step / 2;
return (
<div
ref={track}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${value.toFixed(decimals)}${unit ?? ''}`}
onPointerDown={(e) => {
if (editing || (e.target as HTMLElement).closest('[data-noscrub]')) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDrag(true);
setFromX(e.clientX);
}}
onPointerMove={(e) => drag && setFromX(e.clientX)}
onPointerUp={() => setDrag(false)}
onPointerCancel={() => setDrag(false)}
onKeyDown={(e) => {
if (editing) return;
const big = e.shiftKey ? 10 : 1;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') (e.preventDefault(), onChange(clamp(value + step * big)));
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') (e.preventDefault(), onChange(clamp(value - step * big)));
if (e.key === 'Home') onChange(min);
if (e.key === 'End') onChange(max);
if (e.key === 'Enter') (setDraft(value.toFixed(decimals)), setEditing(true));
}}
className={cn(
'group relative flex h-9 cursor-ew-resize touch-none select-none items-center overflow-hidden rounded-xl bg-zinc-100 ring-1 ring-inset ring-zinc-200/70 transition-shadow',
focusRing,
drag && 'ring-zinc-300',
className,
)}
>
<span className={cn('absolute inset-y-0 left-0 border-r border-zinc-300 bg-zinc-200/80 transition-[width]', drag ? 'duration-0' : 'duration-200')} style={{ width: `${pct}%` }} />
<span className="relative z-10 pl-3 text-[13px] text-zinc-600">{label}</span>
{defaultValue !== undefined && (
<button
data-noscrub
onClick={() => onChange(defaultValue)}
aria-label={`Reset ${label}`}
className={cn('relative z-10 ml-2 grid h-5 w-5 place-items-center rounded-md text-zinc-400 transition-all hover:bg-white hover:text-zinc-800', changed ? 'opacity-100' : 'pointer-events-none opacity-0')}
>
<RotateCcw className="h-3 w-3" />
</button>
)}
<span className="relative z-10 ml-auto pr-3" data-noscrub onDoubleClick={() => (setDraft(value.toFixed(decimals)), setEditing(true))}>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
aria-label={`${label} value`}
className="w-14 rounded-md bg-white px-1.5 py-0.5 text-right font-mono text-[12.5px] text-zinc-900 outline-none ring-1 ring-zinc-300"
/>
) : (
<span className="cursor-text font-mono text-[12.5px] tabular-nums text-zinc-900" title="Double-click to type">
{value.toFixed(decimals)}
{unit && <span className="ml-0.5 text-zinc-400">{unit}</span>}
</span>
)}
</span>
</div>
);
}
export function InlineSliderDemo() {
const [cutoff, setCutoff] = useState(0.5);
const [smooth, setSmooth] = useState(0.6);
const [corner, setCorner] = useState(7.2);
const [opacity, setOpacity] = useState(86);
return (
<LightFrame className="mx-auto max-w-[420px]">
<Panel className="space-y-2.5">
<SectionHead title="Trace" meta="Drag, arrows or double-click" />
<InlineSlider label="Threshold" value={cutoff} onChange={setCutoff} defaultValue={0.5} decimals={2} />
<InlineSlider label="Smoothing" value={smooth} onChange={setSmooth} max={4} step={0.1} unit="px" defaultValue={1} />
<InlineSlider label="Corner radius" value={corner} onChange={setCorner} max={24} step={0.1} unit="px" defaultValue={4} />
<InlineSlider label="Opacity" value={opacity} onChange={setOpacity} max={100} step={1} unit="%" decimals={0} defaultValue={100} />
</Panel>
</LightFrame>
);
}components/niceui/design/ToggleRow.tsx
import { useId, useState } from 'react';
import { cn } from '@/lib/cn';
import { LightFrame, Panel, SectionHead, focusRing } from './shared';
export type ToggleRowProps = {
label: string;
description?: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
};
export function SoftSwitch({ checked, onChange, label, disabled }: { checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
return (
<button
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn('relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200 disabled:opacity-40', focusRing, checked ? 'bg-zinc-900' : 'bg-zinc-200 ring-1 ring-inset ring-zinc-300/60')}
>
<span className={cn('absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.25)] transition-all duration-300 ease-spring', checked ? 'left-[18px]' : 'left-0.5')} />
</button>
);
}
/** A full-width settings row: soft background, label on the left, switch on the right. */
export function ToggleRow({ label, description, checked, onChange, disabled }: ToggleRowProps) {
const id = useId();
return (
<div className={cn('rounded-xl bg-zinc-100 px-3 py-2.5 ring-1 ring-inset ring-zinc-200/70', disabled && 'opacity-60')}>
<div className="flex items-center justify-between gap-3">
<span id={id} className="text-[13px] text-zinc-700">{label}</span>
<SoftSwitch checked={checked} onChange={onChange} label={label} disabled={disabled} />
</div>
{description && <p className="mt-1 text-[12px] leading-snug text-zinc-500">{description}</p>}
</div>
);
}
export function ToggleRowDemo() {
const [holes, setHoles] = useState(false);
const [snap, setSnap] = useState(true);
const [outline, setOutline] = useState(true);
const [grid, setGrid] = useState(false);
return (
<LightFrame className="mx-auto max-w-[420px]">
<Panel className="space-y-5">
<div>
<SectionHead title="Outline" meta="3 paths · dark pixels" />
<div className="space-y-2">
<ToggleRow label="Fill holes" checked={holes} onChange={setHoles} />
<ToggleRow label="Keep stroke" description="Trace the outline as a stroke as well as a fill." checked={outline} onChange={setOutline} />
</div>
</div>
<div className="h-px bg-zinc-100" />
<div>
<SectionHead title="Canvas" meta={snap ? 'Snapping on' : 'Snapping off'} />
<div className="space-y-2">
<ToggleRow label="Snap to pixel grid" checked={snap} onChange={setSnap} />
<ToggleRow label="Show grid" checked={grid} onChange={setGrid} disabled={!snap} />
</div>
</div>
</Panel>
</LightFrame>
);
}