Skip to content
← components

Neumorphic Toggle

new

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.

sun / moon
view: grid

click either side — the thumb slides

Usage

example.tsx
import { NeuToggle } from "~/components/toggle/neu-toggle";
// Sun / moon by default
<NeuToggle value={theme} onChange={setTheme} aria-label="Theme" />
// Or your own two options
<NeuToggle
options={[
{ 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.

neu-toggle.css
/* 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

proptypedefaultdescription
options[Option, Option]sun / moonExactly two options: { value, icon, label }.
valuestringControlled selected value.
defaultValuestringoptions[0]Uncontrolled initial value.
onChange(value) => voidFires on select.
aria-labelstring"Toggle"Label for the radiogroup.

The 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, …).

components/toggle/neu-toggle.tsx
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 (
<div
className={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 (
<button
key={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>
);
}
app/styles/app.css
/* ─── 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 a
raised 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;
}
}