Navigation
Segmented control
Text or icons. One selection at a time.
ReactTypeScriptWhite / Black
Preview
Usage
Segmented control usage
"use client";
import { useState } from "react";
import { SegmentedControl3D } from "@/components/3d-controls";
export default function Example() {
const [view, setView] = useState("grid");
return (
<SegmentedControl3D
label="View"
value={view}
onValueChange={setView}
options={[
{ value: "grid", label: "Grid" },
{ value: "list", label: "List" },
]}
/>
);
}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 { SegmentedControl3D } from "@/components/3d-controls";API reference
| Prop | Type / default | Description |
|---|---|---|
| label | string | Accessible name for the group. |
| options | { value, label, 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
Tab focuses the selected option. Arrow keys change selection. Native radio inputs expose checked and disabled states.
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; }
}