skills/neumorphic-web-design/SKILL.md
Soft UI / neumorphism design — tactile interfaces using paired light and dark box-shadows on matching-background elements. Covers the shadow math, color generation, accessibility fixes for low contrast, inset/extruded states, dark mode adaptation, and when neumorphism works vs when it fails. Activate on 'neumorphism', 'soft UI', 'soft shadows', 'neumorph', 'tactile UI', 'extruded buttons', 'inset cards', 'skeumorphic shadows'. NOT for hard shadows / brutalism (use neobrutalist-web-designer), not for glassmorphism / frosted glass (use vaporwave-glassomorphic-ui-designer).
npx skillsauth add curiositech/windags-skills neumorphic-web-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Creates soft, tactile web interfaces using the neumorphism aesthetic — elements that appear to extrude from or sink into the background surface, like buttons molded from clay. The effect relies on paired directional shadows (one light, one dark) on elements whose background color matches the parent surface.
Neumorphism is the opposite of neobrutalism. Where neobrutalism is hard, opaque, and high-contrast, neumorphism is soft, subtle, and monochromatic. It is beautiful when done right and an accessibility disaster when done wrong. This skill covers both.
Use for:
Do NOT use for:
Every neumorphic element uses two box-shadows: a light highlight (simulating light source) and a dark shadow (simulating depth). The light source is conventionally top-left.
Light source: ↖ top-left
LIGHT shadow DARK shadow
(negative offset, (positive offset,
white/lighter) darker shade)
↗ ↘
┌──────────┐
│ │
│ ELEMENT │
│ │
└──────────┘
The Critical Rule: The element's background color MUST closely match (or exactly match) the parent container's background. Neumorphism breaks visually when element and background colors diverge.
/* Base neumorphic surface */
:root {
--nm-bg: #e0e5ec; /* The shared background color */
--nm-light: #ffffff; /* Highlight shadow color */
--nm-dark: #a3b1c6; /* Depth shadow color */
--nm-distance: 8px; /* Shadow offset distance */
--nm-blur: 16px; /* Shadow blur radius (2x distance) */
--nm-spread: 0px; /* Shadow spread (usually 0) */
--nm-intensity: 1; /* 0-1 opacity multiplier */
}
Generating shadow colors from any background:
| Step | Formula | Example (bg: #e0e5ec) | |------|---------|----------------------| | Light shadow | Lighten background by 15-25% | #ffffff (clamped) | | Dark shadow | Darken background by 15-25% | #a3b1c6 | | Blur radius | 2x the offset distance | 16px for 8px offset |
SCSS/JS Color Generation:
// SCSS function to generate neumorphic shadow colors
@function nm-light($bg, $amount: 20%) {
@return lighten($bg, $amount);
}
@function nm-dark($bg, $amount: 20%) {
@return darken($bg, $amount);
}
// Usage
$nm-bg: #e0e5ec;
$nm-shadow-light: nm-light($nm-bg); // #ffffff (clamped)
$nm-shadow-dark: nm-dark($nm-bg); // #a3b1c6
// JavaScript color generation
function neumorphicShadows(hexBg, amount = 40) {
const r = parseInt(hexBg.slice(1, 3), 16);
const g = parseInt(hexBg.slice(3, 5), 16);
const b = parseInt(hexBg.slice(5, 7), 16);
const light = `rgb(${Math.min(r + amount, 255)}, ${Math.min(g + amount, 255)}, ${Math.min(b + amount, 255)})`;
const dark = `rgb(${Math.max(r - amount, 0)}, ${Math.max(g - amount, 0)}, ${Math.max(b - amount, 0)})`;
return { light, dark };
}
The default neumorphic state — element appears to push out from the surface.
.nm-raised {
background: var(--nm-bg);
border-radius: 12px;
box-shadow:
calc(var(--nm-distance) * -1) calc(var(--nm-distance) * -1) var(--nm-blur) var(--nm-light),
var(--nm-distance) var(--nm-distance) var(--nm-blur) var(--nm-dark);
}
/* Concrete values */
.nm-raised-concrete {
background: #e0e5ec;
border-radius: 12px;
box-shadow:
-8px -8px 16px #ffffff,
8px 8px 16px #a3b1c6;
}
Element appears sunk into the surface — used for active states, input fields, wells.
.nm-inset {
background: var(--nm-bg);
border-radius: 12px;
box-shadow:
inset calc(var(--nm-distance) * -1) calc(var(--nm-distance) * -1) var(--nm-blur) var(--nm-light),
inset var(--nm-distance) var(--nm-distance) var(--nm-blur) var(--nm-dark);
}
/* Concrete values */
.nm-inset-concrete {
background: #e0e5ec;
border-radius: 12px;
box-shadow:
inset -8px -8px 16px #ffffff,
inset 8px 8px 16px #a3b1c6;
}
.nm-button {
background: var(--nm-bg);
border: none;
border-radius: 12px;
padding: 0.875rem 1.75rem;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.15s ease, transform 0.15s ease;
/* Raised state */
box-shadow:
-6px -6px 12px var(--nm-light),
6px 6px 12px var(--nm-dark);
}
.nm-button:hover {
box-shadow:
-8px -8px 16px var(--nm-light),
8px 8px 16px var(--nm-dark);
}
.nm-button:active {
/* Transition to inset on press */
box-shadow:
inset -4px -4px 8px var(--nm-light),
inset 4px 4px 8px var(--nm-dark);
transform: scale(0.98);
}
.nm-button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.nm-toggle {
width: 56px;
height: 28px;
border-radius: 14px;
background: var(--nm-bg);
position: relative;
cursor: pointer;
/* Inset track */
box-shadow:
inset -3px -3px 6px var(--nm-light),
inset 3px 3px 6px var(--nm-dark);
}
.nm-toggle__knob {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--nm-bg);
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
/* Raised knob */
box-shadow:
-2px -2px 4px var(--nm-light),
2px 2px 4px var(--nm-dark);
}
.nm-toggle[aria-checked="true"] .nm-toggle__knob {
transform: translateX(28px);
}
/* Active state tint for accessibility */
.nm-toggle[aria-checked="true"] {
background: color-mix(in srgb, var(--nm-bg) 85%, var(--accent-primary));
}
.nm-card {
background: var(--nm-bg);
border-radius: 16px;
padding: 1.5rem;
box-shadow:
-8px -8px 20px var(--nm-light),
8px 8px 20px var(--nm-dark);
}
.nm-card__header {
font-weight: 700;
font-size: 1.125rem;
margin-bottom: 0.75rem;
color: var(--text-primary); /* MUST meet 4.5:1 contrast */
}
.nm-card__divider {
height: 1px;
background: var(--nm-dark);
opacity: 0.3;
margin: 1rem 0;
}
.nm-input {
background: var(--nm-bg);
border: none;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 1rem;
color: var(--text-primary);
width: 100%;
/* Inset well for inputs */
box-shadow:
inset -3px -3px 6px var(--nm-light),
inset 3px 3px 6px var(--nm-dark);
}
.nm-input:focus {
outline: none;
box-shadow:
inset -3px -3px 6px var(--nm-light),
inset 3px 3px 6px var(--nm-dark),
0 0 0 2px var(--accent-primary); /* Visible focus ring */
}
.nm-input::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
.nm-dial {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--nm-bg);
display: grid;
place-items: center;
box-shadow:
-6px -6px 14px var(--nm-light),
6px 6px 14px var(--nm-dark);
}
.nm-dial__indicator {
width: 4px;
height: 20px;
background: var(--accent-primary);
border-radius: 2px;
transform-origin: center 20px;
}
Neumorphism's original sin was low contrast. Here is how to fix it without losing the aesthetic.
Failure: Light gray text on a light gray background — common in early neumorphism.
Fix: Use high-contrast text colors. The soft shadows provide the aesthetic; the text provides the information.
:root {
/* Neumorphic bg is light gray — text must be near-black */
--text-primary: #2d3748; /* 7.2:1 contrast on #e0e5ec */
--text-secondary: #4a5568; /* 5.1:1 contrast on #e0e5ec */
--text-muted: #718096; /* 3.8:1 — large text only (18px+) */
}
Failure: Users (especially with low vision) cannot distinguish neumorphic elements from the background.
Fix — Subtle Border Enhancement:
.nm-raised-accessible {
background: var(--nm-bg);
border-radius: 12px;
/* Standard neumorphic shadows */
box-shadow:
-8px -8px 16px var(--nm-light),
8px 8px 16px var(--nm-dark);
/* ADD: subtle border for perceivability */
border: 1px solid rgba(0, 0, 0, 0.06);
}
Failure: Buttons look identical to decorative elements. No clear affordance.
Fix — Multi-Signal Interaction:
.nm-button-accessible {
/* Standard neumorphic raised style */
box-shadow:
-6px -6px 12px var(--nm-light),
6px 6px 12px var(--nm-dark);
/* ADD: text color contrast for affordance */
color: var(--accent-primary);
font-weight: 600;
/* ADD: subtle border on interactive elements */
border: 1px solid rgba(0, 0, 0, 0.08);
}
.nm-button-accessible:hover {
/* Intensify shadows AND add color hint */
box-shadow:
-8px -8px 16px var(--nm-light),
8px 8px 16px var(--nm-dark);
background: color-mix(in srgb, var(--nm-bg) 95%, var(--accent-primary));
}
Failure: Default focus outlines clash with or disappear against soft shadows.
Fix:
.nm-interactive:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
/* Keep neumorphic shadows — focus ring sits outside */
}
| Criterion | Requirement | Fix | |-----------|-------------|-----| | 1.4.3 Contrast (Min) | 4.5:1 text contrast | Dark text on light surface | | 1.4.11 Non-Text Contrast | 3:1 for UI components | Add subtle borders to interactive elements | | 2.4.7 Focus Visible | Visible focus indicator | 2px solid accent outline | | 1.4.1 Use of Color | Not color-only distinction | Combine shadow + border + text weight |
Dark mode requires completely recalculated shadows — you cannot simply invert.
/* Dark mode palette */
:root[data-theme="dark"] {
--nm-bg: #2d3748;
--nm-light: #3a4a5e; /* Lighter shade of bg */
--nm-dark: #1a202c; /* Darker shade of bg */
--text-primary: #e2e8f0; /* High contrast light text */
--text-secondary: #a0aec0;
--accent-primary: #63b3ed;
}
/* Dark shadows are more subtle — reduce distance */
:root[data-theme="dark"] {
--nm-distance: 6px;
--nm-blur: 12px;
}
Dark Mode Differences:
| Property | Light Mode | Dark Mode |
|----------|-----------|-----------|
| Background | #e0e5ec | #2d3748 |
| Light shadow | White / near-white | Slightly lighter bg shade |
| Dark shadow | Medium-dark gray | Near-black |
| Shadow distance | 8px | 6px (subtler) |
| Shadow contrast | High | Lower (dark surfaces absorb) |
| Text color | Near-black | Near-white |
Pure neumorphism (2020-era) had problems. Neumorphism 2.0 (2024-2026) retains the soft aesthetic while fixing usability:
/* Neumorphism 2.0: soft card + flat CTA */
.nm2-card {
background: var(--nm-bg);
border-radius: 16px;
padding: 1.5rem;
box-shadow:
-6px -6px 14px var(--nm-light),
6px 6px 14px var(--nm-dark);
border: 1px solid rgba(0, 0, 0, 0.05);
}
/* CTA inside a neumorphic card is FLAT, not neumorphic */
.nm2-card .cta-button {
background: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); /* Standard shadow, NOT neumorphic */
cursor: pointer;
}
| Scenario | Verdict | Reason | |----------|---------|--------| | Music player controls | Works well | Tactile, few elements, playful | | Smart home dashboard | Works well | Physical metaphor matches | | Settings page with toggles | Works well | Toggle/switch is natural fit | | E-commerce product cards | Risky | CTA buttons need clear affordance | | Data-dense tables | Fails | Too many elements, contrast needed | | Form-heavy checkout | Fails | Input fields need clear boundaries | | Text-heavy blog | Fails | Shadows distract from reading | | Accessibility-critical (gov) | Fails | Cannot reliably meet WCAG AA |
:root {
/* Surface */
--nm-bg: #e0e5ec;
/* Shadows */
--nm-light: #ffffff;
--nm-dark: #a3b1c6;
--nm-distance: 8px;
--nm-blur: 16px;
/* Derived shadows (use in box-shadow shorthand) */
--nm-shadow-raised:
calc(var(--nm-distance) * -1) calc(var(--nm-distance) * -1) var(--nm-blur) var(--nm-light),
var(--nm-distance) var(--nm-distance) var(--nm-blur) var(--nm-dark);
--nm-shadow-inset:
inset calc(var(--nm-distance) * -1) calc(var(--nm-distance) * -1) var(--nm-blur) var(--nm-light),
inset var(--nm-distance) var(--nm-distance) var(--nm-blur) var(--nm-dark);
/* Text (high contrast) */
--nm-text-primary: #2d3748;
--nm-text-secondary: #4a5568;
/* Accent */
--nm-accent: #6366f1;
/* Border (accessibility enhancement) */
--nm-border: rgba(0, 0, 0, 0.06);
/* Transitions */
--nm-transition: box-shadow 0.15s ease, transform 0.15s ease;
/* Border radius scale */
--nm-radius-sm: 8px;
--nm-radius-md: 12px;
--nm-radius-lg: 16px;
--nm-radius-full: 9999px;
}
Element background differs from parent surface. The shadows create a visible rectangle instead of an extruded form. Every neumorphic element must match its parent's background color.
"Sign Up" or "Buy Now" buttons styled as subtle neumorphic raised surfaces. Users do not perceive them as clickable. Use flat, high-contrast buttons for primary actions.
A neumorphic card inside a neumorphic panel inside a neumorphic section. The compounding shadows create visual mud. One level of neumorphic depth per view. Use inset for sub-elements.
Gray text on gray background with gray shadows. Looks elegant in Dribbble shots, fails in practice. At minimum: near-black text, an accent color for interactive states, and subtle borders on interactive elements.
Neumorphism with border-radius: 0 looks wrong because real physical objects have softened edges. Minimum border-radius: 8px. Circular for knobs and toggles.
Shadow transitions cause issues for motion-sensitive users.
@media (prefers-reduced-motion: reduce) {
.nm-button {
transition: none;
}
}
Design research based on:
tools
Building resilient distributed systems with circuit breakers, retries with full-jitter exponential backoff, retry budgets (per-request 3-attempt + per-client 10% ratio per Google SRE), deadline propagation, and the cascading-failure math (4 layers × 3 retries = 64x amplification). Grounded in Resilience4j, Microsoft Cloud Patterns, AWS Architecture Blog (Marc Brooker), and Google SRE Book.
testing
Designing HTTP cache headers that work correctly across browsers, CDNs, and shared proxies — `Cache-Control` directives per RFC 9111, `stale-while-revalidate` and `stale-if-error` per RFC 5861, the Vary header for varying responses, and surrogate keys for tag-based purging. Grounded in IETF RFCs and Cloudflare/Fastly docs.
development
Use when designing or fixing a Content Security Policy on a real site, choosing between nonce-based and hash-based CSP, adding strict-dynamic, debugging "Refused to execute inline script" errors, deploying CSP in report-only mode first, configuring report-to / report-uri, or auditing an existing policy for unsafe-inline / unsafe-eval / wildcards. Triggers: "CSP blocks legitimate inline script", strict-dynamic, nonce-{RANDOM}, sha256-{HASH}, object-src none, base-uri none, frame-ancestors, Trusted Types, X-Content-Security-Policy obsolete, report-only vs enforced. NOT for general HTTP security headers (HSTS, COOP/COEP), Trusted Types deep dive, CORS configuration, or building a WAF.
tools
Choosing and operating an HTTP API versioning strategy that doesn't break clients — Stripe's date-based pinned versions, the Deprecation/Sunset header pair (RFC 9745 + RFC 8594), URI vs header vs media-type approaches, and the version-transformer pattern. Grounded in Stripe's published architecture and IETF RFCs.