Navigation
Tabs
A glossy pill slides and resizes to fit the selected tab.
ReactTypeScriptWhite / Black
Preview
MatesMarket
A student marketplace for the HKUST community.
Next.jsTypeScriptTailwind CSS
Verified student accounts, item listings, and mobile layouts.
Usage
Tabs usage
"use client";
import { Tabs3D } from "@/components/3d-controls";
export default function Example() {
return (
<Tabs3D
label="Project details"
colorTheme="black"
tabs={[
{ value: "overview", label: "Overview", content: "..." },
{ value: "stack", label: "Stack", content: "..." },
]}
/>
);
}Installation
Use these components in a Next.js project with Tailwind CSS. Install the dependencies, copy the source files below into your project, and include the shared styles in your global stylesheet. Keep the @/ import alias pointing to your project root.
Terminal
npm install motion lucide-react clsx tailwind-mergeImport
import { Tabs3D } from "@/components/3d-controls";API reference
| Prop | Type / default | Description |
|---|---|---|
| label | string | Accessible name for the group. |
| tabs | { value, label, content, disabled? }[] | Items to display. |
| value | string | Selected value in controlled mode. |
| defaultValue | string | Initial value in uncontrolled mode; defaults to the first available option. |
| onValueChange | (value: string) => void | Called when selection changes. |
| colorTheme | "white" | "black"Default: "black" | Surface finish. |
Accessibility & interaction
Use Left and Right arrows to switch tabs, or Home and End to jump to the first or last enabled tab. Focus and the active panel follow selection.
Source files
Current source used by the preview, including its local dependencies.
components/3d-controls.tsx
components/3d-controls.tsx
"use client";
import { useEffect, useId, useRef, useState, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
type ThemeProps = { colorTheme?: "white" | "black"; className?: string };
type Choice = { value: string; label: ReactNode; disabled?: boolean };
export function IconButton3D({ label, colorTheme = "white", className, children, pressed, ...props }: ThemeProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> & { label: string; pressed?: boolean }) {
return <button {...props} type={props.type ?? "button"} aria-label={label} aria-pressed={pressed} data-theme={colorTheme} data-selected={pressed} className={cn("glass-control glass-icon", className)}>{children}</button>;
}
type CheckProps = ThemeProps & Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "className"> & { label: ReactNode };
export function Checkbox3D({ label, colorTheme = "black", className, ...props }: CheckProps) {
return (
<label className={cn("glass-check-label", className)}>
<input {...props} type="checkbox" className="glass-native-input" />
<span aria-hidden="true" data-theme={colorTheme} className="glass-control glass-check"><Check size={15} strokeWidth={3} /></span>
<span>{label}</span>
</label>
);
}
export function Switch3D({ label, colorTheme = "black", className, ...props }: CheckProps) {
return (
<label className={cn("glass-check-label", className)}>
<input {...props} type="checkbox" role="switch" className="glass-native-input" />
<span aria-hidden="true" data-theme={colorTheme} className="glass-switch"><span className="glass-control glass-switch-thumb" data-theme="white" style={{ transform: "translateX(calc(var(--switch-progress) * 20px))", transition: "none" }} /></span>
<span>{label}</span>
</label>
);
}
type ChoiceProps = ThemeProps & { options: Choice[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; label: string; disabled?: boolean };
export function SegmentedControl3D({ options, value, defaultValue, onValueChange, label, colorTheme = "black", className, disabled = false }: ChoiceProps) {
const name = useId();
const [internal, setInternal] = useState(defaultValue ?? options.find((option) => !option.disabled)?.value ?? "");
const selected = value ?? internal;
return (
<div role="radiogroup" aria-label={label} className={cn("glass-tray", className)}>
{options.map((option) => (
<label key={option.value} className="glass-segment-label">
<input type="radio" name={name} value={option.value} checked={selected === option.value} disabled={disabled || option.disabled} className="glass-native-input" onChange={() => { setInternal(option.value); onValueChange?.(option.value); }} />
<span data-theme={colorTheme} className="glass-control glass-segment">{option.label}</span>
</label>
))}
</div>
);
}
type Tab = Choice & { content: ReactNode };
export function Tabs3D({ tabs, value, defaultValue, onValueChange, label, colorTheme = "black", className }: Omit<ChoiceProps, "options" | "disabled"> & { tabs: Tab[] }) {
const id = useId();
const reducedMotion = useReducedMotion();
const trayRef = useRef<HTMLDivElement>(null);
const initialized = useRef(false);
const left = useMotionValue(0);
const indicatorWidth = useMotionValue(0);
const opacity = useTransform(indicatorWidth, (width) => width > 0 ? 1 : 0);
const [internal, setInternal] = useState(defaultValue ?? tabs.find((tab) => !tab.disabled)?.value ?? "");
const selected = value ?? internal;
const [hovered, setHovered] = useState<string | null>(null);
useEffect(() => {
const tray = trayRef.current;
if (!tray) return;
let stopLeft = () => {};
let stopWidth = () => {};
const measure = () => {
const active = tray.querySelector<HTMLButtonElement>('[aria-selected="true"]');
if (!active) return;
stopLeft();
stopWidth();
if (!initialized.current || reducedMotion) {
left.set(active.offsetLeft);
indicatorWidth.set(active.offsetWidth);
initialized.current = true;
} else {
const transition = { duration: 0.16, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] };
const slide = animate(left, active.offsetLeft, transition);
const resize = animate(indicatorWidth, active.offsetWidth, transition);
stopLeft = () => slide.stop();
stopWidth = () => resize.stop();
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(tray);
tray.querySelectorAll('button').forEach((button) => observer.observe(button));
return () => { observer.disconnect(); stopLeft(); stopWidth(); };
}, [selected, left, indicatorWidth, reducedMotion]);
const select = (next: string) => { setInternal(next); onValueChange?.(next); };
return (
<div className={className}>
<div ref={trayRef} role="tablist" aria-label={label} className="glass-tray glass-tabs-tray max-w-full" style={{ overflow: "clip" }}>
<motion.span
aria-hidden="true"
className="glass-tab-indicator pointer-events-none rounded-full"
style={{ position: "absolute", top: 5, bottom: 5, left: 0, zIndex: 0, x: left, width: indicatorWidth, opacity }}
>
<motion.span
data-theme={colorTheme}
className="glass-control rounded-full"
style={{ position: "absolute", inset: 0, transition: "none" }}
animate={{ scale: !reducedMotion && hovered === selected ? 1.04 : 1 }}
transition={{ duration: reducedMotion ? 0 : 0.14, ease: "easeOut" }}
/>
</motion.span>
{tabs.map((tab, index) => (
<button key={tab.value} type="button" role="tab" id={`${id}-tab-${index}`} aria-selected={selected === tab.value} aria-controls={`${id}-panel-${index}`} tabIndex={selected === tab.value ? 0 : -1} disabled={tab.disabled} data-selected={selected === tab.value} data-theme={colorTheme} className="glass-tab" onPointerEnter={(event) => { if (event.pointerType === "mouse" && !tab.disabled) setHovered(tab.value); }} onPointerLeave={() => setHovered(null)} onClick={() => select(tab.value)} onKeyDown={(event) => {
if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const enabled = tabs.filter((item) => !item.disabled);
if (!enabled.length) return;
const current = enabled.findIndex((item) => item.value === tab.value);
const next = event.key === "Home" ? enabled[0] : event.key === "End" ? enabled[enabled.length - 1] : enabled[(current + (event.key === "ArrowRight" ? 1 : -1) + enabled.length) % enabled.length];
select(next.value);
const nextIndex = tabs.findIndex((item) => item.value === next.value);
document.getElementById(`${id}-tab-${nextIndex}`)?.focus();
}}>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => <div key={tab.value} role="tabpanel" id={`${id}-panel-${index}`} aria-labelledby={`${id}-tab-${index}`} hidden={selected !== tab.value} tabIndex={0} className="mt-5 text-sm leading-relaxed text-zinc-600">{tab.content}</div>)}
</div>
);
}
lib/utils.ts
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Shared styles
globals.css
/* Glossy controls: shared material, lighting, and pressed depth. */
.glass-control {
--glass-face: linear-gradient(135deg, #fff, #f9fafb 48%, #e5e7eb);
--glass-ink: #18181b;
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 0.5rem;
border: 1px solid rgb(255 255 255 / 0.55);
background: var(--glass-face);
color: var(--glass-ink);
box-shadow: inset 0 1px 1px rgb(255 255 255 / 0.85), inset 0 -2px 4px rgb(0 0 0 / 0.07), 0 3px 5px rgb(24 24 27 / 0.1), 0 8px 16px -8px rgb(24 24 27 / 0.18);
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, color 180ms ease, background 180ms ease;
}
.glass-control[data-theme="black"] {
--glass-face: linear-gradient(135deg, #52525b, #27272a 55%, #18181b);
--glass-ink: #fff;
border-color: rgb(63 63 70 / 0.65);
box-shadow: inset 0 1px 1px rgb(255 255 255 / 0.2), inset 0 -2px 4px rgb(0 0 0 / 0.25), 0 3px 5px rgb(24 24 27 / 0.15), 0 8px 16px -8px rgb(24 24 27 / 0.25);
}
.glass-control::before {
content: "";
position: absolute;
z-index: -1;
inset: 3px 5px 45%;
border-radius: inherit;
background: linear-gradient(rgb(255 255 255 / 0.28), transparent);
filter: blur(2px);
pointer-events: none;
}
.glass-control:hover:not(:disabled) { transform: translateY(-1px); }
.glass-control:active:not(:disabled) { transform: translateY(1px) scale(0.97); }
.glass-control:focus-visible,
.glass-native-input:focus-visible + span {
outline: 2px solid #527349;
outline-offset: 4px;
}
.glass-control:disabled { opacity: 0.4; cursor: not-allowed; }
.glass-icon { width: 44px; height: 44px; border-radius: 50%; }
.glass-icon svg { width: 18px; height: 18px; }
.glass-icon[data-selected="true"] { color: #166534; }
.glass-icon[data-selected="true"][data-theme="black"] { color: #86efac; }
.glass-tray {
display: inline-flex;
gap: 4px;
padding: 5px;
border: 1px solid #dededb;
border-radius: 999px;
background: #e9e9e6;
box-shadow: inset 0 2px 4px rgb(24 24 27 / 0.08), 0 1px 0 rgb(255 255 255 / 0.9);
}
.glass-tab, .glass-segment { min-height: 38px; padding: 0 18px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.glass-tab[aria-selected="false"],
.glass-segment-label input:not(:checked) + .glass-segment {
background: transparent;
border-color: transparent;
color: #52525b;
box-shadow: none;
}
.glass-tab[aria-selected="false"]::before,
.glass-segment-label input:not(:checked) + .glass-segment::before { opacity: 0; }
.glass-check-label, .glass-segment-label { position: relative; display: inline-flex; align-items: center; gap: 12px; cursor: pointer; font-size: 14px; color: #3f3f46; }
.glass-native-input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.glass-check { width: 26px; height: 26px; border-radius: 8px; }
.glass-check svg { opacity: 0; transform: scale(0.65); transition: opacity 150ms ease, transform 150ms ease; }
.glass-native-input:checked + .glass-check svg { opacity: 1; transform: scale(1); }
.glass-native-input:not(:checked) + .glass-check { --glass-face: linear-gradient(135deg, #fff, #f4f4f5); --glass-ink: #18181b; border-color: #d4d4d8; box-shadow: inset 0 1px 3px rgb(24 24 27 / 0.09), 0 1px 0 white; }
.glass-native-input:disabled ~ span { opacity: 0.4; cursor: not-allowed; }
.glass-check-label:has(input:disabled), .glass-segment-label:has(input:disabled) { cursor: not-allowed; }
@property --switch-progress {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.glass-switch {
--switch-progress: 0;
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
width: 52px;
height: 30px;
padding: 3px;
border: 1px solid #d4d4d8;
border-radius: 999px;
background: #dededb;
box-shadow: inset 0 2px 5px rgb(24 24 27 / 0.16), 0 1px 0 white;
transition: --switch-progress 180ms ease;
}
.glass-switch::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: linear-gradient(135deg, #52525b, #18181b);
clip-path: inset(0 calc(100% - var(--switch-progress) * 100%) 0 0);
pointer-events: none;
}
.glass-switch[data-theme="white"]::after { background: linear-gradient(135deg, #a7c2a6, #527349); }
.glass-switch .glass-switch-thumb {
position: relative;
z-index: 1;
width: 24px;
height: 24px;
border-radius: 50%;
}
.glass-native-input:checked + .glass-switch { --switch-progress: 1; }
@media (prefers-reduced-motion: reduce) {
.glass-control, .glass-control::before, .glass-check svg,
.glass-switch, .glass-switch-thumb { transition: none; }
}
.liquid-track {
display: inline-grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
position: relative;
width: max-content;
max-width: 100%;
touch-action: pan-y;
user-select: none;
cursor: grab;
}
.liquid-track[data-dragging="true"] { cursor: grabbing; }
.liquid-track[data-disabled="true"] { opacity: 0.4; cursor: not-allowed; }
.liquid-surface { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
.liquid-option { display: flex; align-items: center; justify-content: center; position: relative; z-index: 1; flex: 1 0 0; min-width: 0; min-height: 38px; padding: 0 18px; white-space: nowrap; border-radius: 999px; color: #52525b; font-size: 12px; font-weight: 500; cursor: inherit; transition: color 150ms ease; }
.liquid-option[data-active="true"] { color: #18181b; }
.liquid-option[data-active="true"][data-theme="black"] { color: white; }
.liquid-option:focus-visible { outline: 2px solid #527349; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .liquid-option { transition: none; } }
.glass-tab {
position: relative;
isolation: isolate;
flex-shrink: 0;
border: 0;
background: transparent;
color: #52525b;
cursor: pointer;
transition: color 180ms ease;
}
.glass-tab[aria-selected="true"] { color: #18181b; }
.glass-tab[aria-selected="true"][data-theme="black"] { color: #fff; }
.glass-tab:disabled { opacity: 0.4; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { .glass-tab { transition: none; } }
.glass-tabs-tray { position: relative; isolation: isolate; }
.glass-tabs-tray > .glass-tab { z-index: 1; isolation: auto; }
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.glass-tabs-tray > .glass-tab {
transition: color 140ms ease, scale 140ms ease-out;
}
.glass-tabs-tray > .glass-tab:hover:not(:disabled) { scale: 1.04; }
}