Inputs
Switch
A glossy thumb with a sliding on/off state.
ReactTypeScriptWhite / Black
Preview
Usage
Switch usage
"use client";
import { useState } from "react";
import { Switch3D } from "@/components/3d-controls";
export default function Example() {
const [enabled, setEnabled] = useState(true);
return (
<Switch3D
label="Notifications"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
);
}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 { Switch3D } from "@/components/3d-controls";API reference
| Prop | Type / default | Description |
|---|---|---|
| label | ReactNode | Visible label for the input. |
| checked | boolean | Checked state in controlled mode. |
| defaultChecked | booleanDefault: false | Initial native input state. |
| onChange | ChangeEventHandler<HTMLInputElement> | Native input change handler. |
| disabled | booleanDefault: false | Disables interaction. |
| colorTheme | "white" | "black"Default: "black" | Surface finish. |
Accessibility & interaction
Tab focuses the input. Space toggles its state. Disabled inputs are skipped. Native input props such as name, required, and form 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; }
}