Navigation

Tabs

A glossy pill slides and resizes to fit the selected tab.

ReactTypeScriptWhite / Black

Preview

MatesMarket

A student marketplace for the HKUST community.

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-merge
Import
import { Tabs3D } from "@/components/3d-controls";

API reference

PropType / defaultDescription
labelstringAccessible name for the group.
tabs{ value, label, content, disabled? }[]Items to display.
valuestringSelected value in controlled mode.
defaultValuestringInitial value in uncontrolled mode; defaults to the first available option.
onValueChange(value: string) => voidCalled 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; }
}