Actions

Icon button

Compact actions with a tactile press.

ReactTypeScriptWhite / Black

Preview

0 items added

Usage

Icon button usage
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { IconButton3D } from "@/components/3d-controls";

export default function Example() {
  const [count, setCount] = useState(0);
  return (
    <IconButton3D
      label="Add item"
      colorTheme="black"
      onClick={() => setCount(count + 1)}
    >
      <Plus />
    </IconButton3D>
  );
}

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 { IconButton3D } from "@/components/3d-controls";

API reference

PropType / defaultDescription
labelstringRequired accessible name for the icon action.
colorTheme"white" | "black"Default: "white"Surface finish.
pressedbooleanSets aria-pressed for a toggle button.
disabledbooleanDefault: falseDisables the action.
onClickMouseEventHandler<HTMLButtonElement>Native button click handler.

Accessibility & interaction

Tab focuses the button. Enter or Space activates it. Use pressed to expose toggle state to assistive technology. Native button props are forwarded.

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; }
}