Neumorphic Toggle
A soft, pressed-in segmented toggle — a gradient pill with two opposing inset shadows and a raised thumb that slides between sun and moon. Pure CSS.
click either side — the thumb slides
Usage
import { NeuToggle } from "~/components/toggle/neu-toggle";// Sun / moon by default<NeuToggle value={theme} onChange={setTheme} aria-label="Theme" />// Or your own two options<NeuToggleoptions={[{ value: "list", icon: <List />, label: "List" },{ value: "grid", icon: <LayoutGrid />, label: "Grid" },]}defaultValue="grid"/>
How the softness works
It started as a Sketch recipe: a pill with a left-to-right gradient, then two inset shadows pointing opposite ways. One dark (#171717) pushed in from one side, one light (#494949) from the other. Those opposing shadows are the whole neumorphic trick. The surface looks like it was pressed out of a single soft material instead of drawn with a border.
I added the part the static recipe leaves out: a raised thumb that actually moves. It's a rounded knob with its own drop shadow and a top highlight, and it slides between the two halves with a translateX on a springy ease. The icons sit above it, so whichever one the thumb is under reads as bright and the other dims back. Keyboard and screen readers get a real radiogroup.
/* The track: gradient + two opposing inset shadows = the soft pressed look. */.neu-toggle {border-radius: 100px;background: linear-gradient(90deg, #4b4b4b, #111111);box-shadow:inset 20px 0 30px -10px #171717, /* dark side */inset -20px 0 30px -10px #494949, /* light side */0 1px 2px rgba(0, 0, 0, 0.5);}/* The raised knob, sliding between halves. */.neu-toggle__thumb {position: absolute; top: 6px; left: 6px;width: calc(50% - 6px); height: calc(100% - 12px);border-radius: 100px;background: linear-gradient(180deg, #3c3c3c, #1b1b1b);box-shadow: 0 2px 6px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.1);transition: transform .28s cubic-bezier(.2,.7,.2,1);}.neu-toggle[data-index="1"] .neu-toggle__thumb { transform: translateX(100%); }
Props
| prop | type | default | description |
|---|---|---|---|
| options | [Option, Option] | sun / moon | Exactly two options: { value, icon, label }. |
| value | string | — | Controlled selected value. |
| defaultValue | string | options[0] | Uncontrolled initial value. |
| onChange | (value) => void | — | Fires on select. |
| aria-label | string | "Toggle" | Label for the radiogroup. |
Source
download .zipThe full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { useState } from "react";import { Sun, Moon } from "lucide-react";import { cn } from "~/lib/utils";export interface NeuToggleOption {value: string;/** Icon element (e.g. a lucide <Sun />). */icon?: React.ReactNode;/** Accessible label. */label: string;}export interface NeuToggleProps {/** Exactly two options. Defaults to sun / moon. */options?: [NeuToggleOption, NeuToggleOption];/** Controlled value. */value?: string;/** Uncontrolled initial value. */defaultValue?: string;onChange?: (value: string) => void;className?: string;"aria-label"?: string;}const DEFAULT_OPTIONS: [NeuToggleOption, NeuToggleOption] = [{ value: "light", icon: <Sun />, label: "Light" },{ value: "dark", icon: <Moon />, label: "Dark" },];/*** A neumorphic segmented toggle. The track is a left→right gradient with two* opposing inset shadows (dark one side, light the other) for the soft pressed* depth; a raised thumb slides between the two options. Styling lives in* `.neu-toggle` (app.css); works with or without Tailwind.*/export function NeuToggle({options = DEFAULT_OPTIONS,value,defaultValue,onChange,className,"aria-label": ariaLabel = "Toggle",}: NeuToggleProps) {const controlled = value !== undefined;const [internal, setInternal] = useState(defaultValue ?? options[0].value);const current = controlled ? value : internal;const index = Math.max(0, options.findIndex((o) => o.value === current));function select(v: string) {if (!controlled) setInternal(v);onChange?.(v);}return (<divclassName={cn("neu-toggle", className)}data-index={index}role="radiogroup"aria-label={ariaLabel}><span className="neu-toggle__thumb" aria-hidden="true" />{options.map((o) => {const active = o.value === current;return (<buttonkey={o.value}type="button"role="radio"aria-checked={active}aria-label={o.label}title={o.label}data-active={active}className="neu-toggle__opt"onClick={() => select(o.value)}>{o.icon ?? o.label}</button>);})}</div>);}
/* ─── Neumorphic segmented toggle (sun / moon) ─────────────────────── *//* Recipe: a pill with a left→right gradient and two opposing inset shadows(dark from one side, light from the other) for the soft pressed look, plus araised thumb that slides between the two options. */.neu-toggle {position: relative;display: inline-flex;padding: 6px;border-radius: 100px;border: none;background: linear-gradient(90deg, #4b4b4b, #111111);box-shadow:inset 20px 0 30px -10px #171717,inset -20px 0 30px -10px #494949,0 1px 2px rgba(0, 0, 0, 0.5);}.neu-toggle:focus-within {outline: 2px solid hsl(var(--ring));outline-offset: 3px;}/* the raised knob */.neu-toggle__thumb {position: absolute;top: 6px;left: 6px;width: calc(50% - 6px);height: calc(100% - 12px);border-radius: 100px;background: linear-gradient(180deg, #3c3c3c, #1b1b1b);box-shadow:0 2px 6px rgba(0, 0, 0, 0.6),inset 0 1px 0 rgba(255, 255, 255, 0.1),inset 0 -1px 1px rgba(0, 0, 0, 0.5);transition: transform 0.28s cubic-bezier(0.2, 0.7, 0.2, 1);pointer-events: none;}.neu-toggle[data-index="1"] .neu-toggle__thumb {transform: translateX(100%);}/* options */.neu-toggle__opt {position: relative;z-index: 1;display: grid;place-items: center;width: 48px;height: 40px;cursor: pointer;border: none;background: transparent;color: #5a5a5a;transition:color 0.2s ease,transform 0.1s ease;}.neu-toggle__opt[data-active="true"] {color: #ededed;}.neu-toggle__opt:active {transform: scale(0.92);}.neu-toggle__opt :where(svg) {width: 20px;height: 20px;}@media (prefers-reduced-motion: reduce) {.neu-toggle__thumb {transition: none;}}