CSS Neumorphism: 16 Examples (And the Contrast Problem)

Soft UI controls that look raised out of the page or pushed into it come down to two box-shadows on an element that matches its parent's color.

The catch: the common #e0e5ec surface against its #babecc shadow measures 1.46:1, under the 3:1 WCAG 2.2 minimum for controls. Below are 16 CSS neumorphism examples that pass, followed by a guide to building and fixing the style.

By Bogdan Sandu · Updated October 28, 2026

Neumorphic buttons and form controls

Raised and pressed controls built from two box-shadows on a surface that matches its parent. Every control also has a border or fill that reaches 3:1 against the surface, because the shadows on their own measure under 1.5:1.

Raised and pressed toggle buttons

  • aria-pressed
  • inset box-shadow
  • padded shadow lists

Draft not saved yet

A formatting toolbar where each toggle sinks into the surface when aria-pressed is true, and a momentary Save button that does the same on :active. Both shadow lists hold four entries in the same order, so the raised-to-inset swap eases instead of snapping. The pressed state also turns the border and an underline bar blue (5.09:1), so it never depends on depth alone.

View code
.nm-press {
  display: grid;
  place-items: center;
  gap: 26px;
  min-height: 340px;
  padding: 48px 16px;
  background: #e0e5ec;
  color: #2d3440;
}
.nm-press__bar {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 14px;
}
.nm-press__btn,
.nm-press__save {
  font: inherit;
  color: #2d3440;
  background: #e0e5ec;
  border: 1px solid #767f91;
  cursor: pointer;
  box-shadow: 6px 6px 12px #babecc, -6px -6px 12px #ffffff, inset 0 0 0 #babecc, inset 0 0 0 #ffffff;
}
.nm-press__btn {
  position: relative;
  width: 56px;
  height: 56px;
  border-radius: 16px;
  font-size: 20px;
  line-height: 1;
}
.nm-press__btn b,
.nm-press__btn i,
.nm-press__btn u,
.nm-press__btn s {
  font-family: "Fraunces", Georgia, serif;
}
.nm-press__btn::after {
  content: "";
  position: absolute;
  left: 17px;
  right: 17px;
  bottom: 8px;
  height: 3px;
  border-radius: 3px;
  background: #2f55c8;
  opacity: 0;
}
.nm-press__btn[aria-pressed="true"] {
  color: #2f55c8;
  border-color: #2f55c8;
  box-shadow: 0 0 0 #babecc, 0 0 0 #ffffff, inset 4px 4px 8px #babecc, inset -4px -4px 8px #ffffff;
}
.nm-press__btn[aria-pressed="true"]::after {
  opacity: 1;
}
.nm-press__save {
  min-height: 48px;
  padding: 0 30px;
  border-radius: 14px;
  font-weight: 600;
}
.nm-press__save:active {
  box-shadow: 0 0 0 #babecc, 0 0 0 #ffffff, inset 4px 4px 8px #babecc, inset -4px -4px 8px #ffffff;
}
.nm-press__btn:focus-visible,
.nm-press__save:focus-visible {
  outline: 3px solid #2f55c8;
  outline-offset: 4px;
}
.nm-press__status {
  margin: 0;
  font-size: 14px;
  color: #4a5568;
}
@media (prefers-reduced-motion: no-preference) {
  .nm-press__btn,
  .nm-press__save {
    transition: box-shadow .2s ease, color .2s ease, border-color .2s ease;
  }
}

Round icon buttons on a recipe card

  • aria-pressed
  • inline SVG
  • currentColor fill

Smoked paprika shakshuka

25 min · serves 2 · one pan

Icon-only buttons get an aria-label and keep the label fixed while aria-pressed changes. Pressed icons switch from outline to filled terracotta, a change of shape and color that survives without the shadow. The sand border measures 3.26:1 against the card.

View code
.nm-icons {
  display: grid;
  place-items: center;
  min-height: 340px;
  padding: 44px 16px;
  background: #ebe3d8;
  color: #3b3026;
}
.nm-icons__card {
  width: min(100%, 340px);
  padding: 26px 22px 24px;
  border: 1px solid #8a7a66;
  border-radius: 26px;
  background: #ebe3d8;
  box-shadow: 10px 10px 22px #c9bfb2, -10px -10px 22px #fffaf3;
}
.nm-icons__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  line-height: 1.2;
  color: #3b3026;
}
.nm-icons__meta {
  margin: 6px 0 22px;
  font-size: 14px;
  color: #6b5d4c;
}
.nm-icons__row {
  display: flex;
  justify-content: space-between;
  gap: 10px;
}
.nm-icons__btn {
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  padding: 0;
  border: 1px solid #8a7a66;
  border-radius: 50%;
  background: #ebe3d8;
  color: #3b3026;
  cursor: pointer;
  box-shadow: 5px 5px 10px #c9bfb2, -5px -5px 10px #fffaf3, inset 0 0 0 #c9bfb2, inset 0 0 0 #fffaf3;
}
.nm-icons__btn svg {
  width: 24px;
  height: 24px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.nm-icons__btn[aria-pressed="true"] {
  color: #a63c1a;
  border-color: #a63c1a;
  box-shadow: 0 0 0 #c9bfb2, 0 0 0 #fffaf3, inset 3px 3px 7px #c9bfb2, inset -3px -3px 7px #fffaf3;
}
.nm-icons__btn[aria-pressed="true"] svg {
  fill: currentColor;
}
.nm-icons__btn[aria-pressed="true"] .nm-icons__line {
  fill: none;
}
.nm-icons__btn--plain:active {
  box-shadow: 0 0 0 #c9bfb2, 0 0 0 #fffaf3, inset 3px 3px 7px #c9bfb2, inset -3px -3px 7px #fffaf3;
}
.nm-icons__btn:focus-visible {
  outline: 3px solid #a63c1a;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .nm-icons__btn {
    transition: box-shadow .2s ease, color .2s ease, border-color .2s ease;
  }
}

Checkbox and radio set

  • appearance: none
  • fieldset + legend
  • forced-colors
Delivery
Extras

Native inputs with appearance: none, drawn as inset wells. Checked boxes fill with violet and a white check, and checked radios get a violet dot, so the state reads as a filled shape rather than a deeper shadow. In forced colors mode the inputs go back to appearance: auto and the system draws them.

View code
.nm-check {
  display: grid;
  place-items: center;
  min-height: 340px;
  padding: 44px 16px;
  background: #e4e2ef;
  color: #2b2840;
}
.nm-check__card {
  display: flex;
  flex-wrap: wrap;
  gap: 18px 40px;
  width: min(100%, 460px);
  padding: 24px 26px;
  border-radius: 24px;
  background: #e4e2ef;
  box-shadow: 9px 9px 20px #c4c1d6, -9px -9px 20px #ffffff;
}
.nm-check__set {
  flex: 1 1 160px;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}
.nm-check__set legend {
  margin-bottom: 10px;
  padding: 0;
  font-weight: 700;
  font-size: 15px;
  color: #2b2840;
}
.nm-check__set label {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  font-size: 15px;
  cursor: pointer;
}
.nm-check__set input {
  flex: none;
  appearance: none;
  width: 26px;
  height: 26px;
  margin: 0;
  border: 1px solid #777390;
  background: #e4e2ef center / 18px no-repeat;
  box-shadow: inset 3px 3px 6px #c4c1d6, inset -3px -3px 6px #ffffff;
  cursor: pointer;
}
.nm-check__set input[type="checkbox"] {
  border-radius: 8px;
}
.nm-check__set input[type="radio"] {
  border-radius: 50%;
}
.nm-check__set input[type="checkbox"]:checked {
  border-color: #5b3fc4;
  background-color: #5b3fc4;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  box-shadow: 2px 2px 5px #c4c1d6, -2px -2px 5px #ffffff;
}
.nm-check__set input[type="radio"]:checked {
  border-color: #5b3fc4;
  background-color: #5b3fc4;
  box-shadow: inset 0 0 0 6px #e4e2ef;
}
.nm-check__set input:focus-visible {
  outline: 3px solid #5b3fc4;
  outline-offset: 3px;
}
@media (forced-colors: active) {
  .nm-check__set input {
    appearance: auto;
  }
}

Toggle switches with an on-state fill

  • role="switch"
  • inset track
  • :checked + sibling

The track is inset and the knob raised, which is why toggles suit this style. The on state fills the track green (5.37:1 against the panel) so the switch reads correctly even when the shadows are too faint to see. The checkbox is visually hidden but keeps role="switch" and its label row as the click target.

View code
.nm-tog {
  display: grid;
  place-items: center;
  min-height: 340px;
  padding: 44px 16px;
  background: #dfe7ef;
  color: #243241;
}
.nm-tog__card {
  width: min(100%, 340px);
  padding: 10px 22px;
  border-radius: 24px;
  background: #dfe7ef;
  box-shadow: 9px 9px 20px #bcc6d1, -9px -9px 20px #ffffff;
}
.nm-tog__row {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  min-height: 64px;
  font-size: 15px;
  cursor: pointer;
}
.nm-tog__row + .nm-tog__row {
  border-top: 1px solid #c4ced9;
}
.nm-tog__input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}
.nm-tog__track {
  flex: none;
  position: relative;
  width: 58px;
  height: 32px;
  border: 1px solid #71808f;
  border-radius: 999px;
  background: #dfe7ef;
  box-shadow: inset 3px 3px 6px #bcc6d1, inset -3px -3px 6px #ffffff;
}
.nm-tog__knob {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 24px;
  height: 24px;
  border: 1px solid #71808f;
  border-radius: 50%;
  background: #dfe7ef;
  box-shadow: 2px 2px 4px #a9b4c0, -1px -1px 3px #ffffff;
}
.nm-tog__input:checked + .nm-tog__track {
  border-color: #18693f;
  background: #18693f;
  box-shadow: inset 2px 2px 5px #0f4a2b;
}
.nm-tog__input:checked + .nm-tog__track .nm-tog__knob {
  border-color: #0f4a2b;
  transform: translateX(26px);
}
.nm-tog__input:focus-visible + .nm-tog__track {
  outline: 3px solid #18693f;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .nm-tog__knob {
    transition: transform .2s ease;
  }
  .nm-tog__track {
    transition: background-color .2s ease, border-color .2s ease;
  }
}
@media (forced-colors: active) {
  .nm-tog__input:checked + .nm-tog__track {
    background: Highlight;
  }
}

Inset slider with a colored fill

  • input type="range"
  • custom property
  • aria-valuetext
Medium, 16

A native range input sits over an inset track, and a small script writes the value into a custom property that sizes the fill. The rust fill is what shows progress (5.32:1), because an all-shadow slider gives no sense of how far along you are. The thumb is raised and bordered, and aria-valuetext announces the grind name with the number.

View code
.nm-rng {
  display: grid;
  place-items: center;
  min-height: 340px;
  padding: 44px 16px;
  background: #efe3dc;
  color: #3a2a22;
}
.nm-rng__card {
  width: min(100%, 380px);
  padding: 24px 24px 20px;
  border-radius: 24px;
  background: #efe3dc;
  box-shadow: 9px 9px 20px #cfc0b6, -9px -9px 20px #ffffff;
}
.nm-rng__head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 14px;
  font-size: 15px;
}
.nm-rng__head label {
  font-weight: 700;
}
.nm-rng__out {
  font-variant-numeric: tabular-nums;
  color: #9e3d17;
  font-weight: 600;
}
.nm-rng__field {
  position: relative;
  height: 44px;
}
.nm-rng__track {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 16px;
  margin-top: -8px;
  border: 1px solid #8c7466;
  border-radius: 999px;
  background: #efe3dc;
  box-shadow: inset 3px 3px 6px #cfc0b6, inset -3px -3px 6px #ffffff;
  overflow: hidden;
}
.nm-rng__fill {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: calc(13px + (100% - 26px) * var(--nm-rng-pct) / 100);
  background: #9e3d17;
}
.nm-rng__input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 44px;
  margin: 0;
  background: transparent;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}
.nm-rng__input::-webkit-slider-runnable-track {
  height: 44px;
  background: transparent;
}
.nm-rng__input::-moz-range-track {
  height: 44px;
  background: transparent;
}
.nm-rng__input::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 30px;
  height: 30px;
  margin-top: 7px;
  border: 1px solid #8c7466;
  border-radius: 50%;
  background: #efe3dc;
  box-shadow: 3px 3px 6px #bfaea3, -3px -3px 6px #ffffff;
}
.nm-rng__input::-moz-range-thumb {
  width: 28px;
  height: 28px;
  border: 1px solid #8c7466;
  border-radius: 50%;
  background: #efe3dc;
  box-shadow: 3px 3px 6px #bfaea3, -3px -3px 6px #ffffff;
}
.nm-rng__input:focus-visible {
  outline: none;
}
.nm-rng__input:focus-visible::-webkit-slider-thumb {
  outline: 3px solid #9e3d17;
  outline-offset: 3px;
}
.nm-rng__input:focus-visible::-moz-range-thumb {
  outline: 3px solid #9e3d17;
  outline-offset: 3px;
}
.nm-rng__scale {
  display: flex;
  justify-content: space-between;
  margin-top: 8px;
  font-size: 13px;
  color: #6b5548;
}

Neumorphic UI components

Full components in the soft UI style: a player, a keypad, a thermostat, a sign-in card, a progress ring, a tab bar and dashboard cards. State is always carried by color, text or an icon, with the shadows as the finish on top.

Music player card

  • aria-pressed play
  • inset progress
  • object-fit
Album cover: an orange jellyfish drifting in deep blue water

Low Tide Radio

Marin Okafor · Saltwater Hours

The classic neumorphism showcase, with the fixes built in. The cover sits in a raised ring, the progress bar is an inset groove with an indigo fill, and the play button is a solid indigo control (7.9:1 for its icon) that sinks into the card while playing. Prev and next keep a 3.36:1 border.

View code
.nm-player {
  display: grid;
  place-items: center;
  padding: 44px 16px;
  background: #e3e6ea;
  color: #262b33;
}
.nm-player__card {
  width: min(100%, 320px);
  padding: 26px 24px 24px;
  border-radius: 30px;
  background: #e3e6ea;
  text-align: center;
  box-shadow: 12px 12px 26px #c1c6cd, -12px -12px 26px #ffffff;
}
.nm-player__art {
  width: 172px;
  height: 172px;
  margin: 0 auto 20px;
  padding: 10px;
  border-radius: 50%;
  background: #e3e6ea;
  box-shadow: 8px 8px 16px #c1c6cd, -8px -8px 16px #ffffff;
}
.nm-player__art img {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  object-position: 62% 35%;
}
.nm-player__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  line-height: 1.2;
  color: #262b33;
}
.nm-player__artist {
  margin: 4px 0 18px;
  font-size: 14px;
  color: #4b5260;
}
.nm-player__bar {
  height: 10px;
  border: 1px solid #747c88;
  border-radius: 999px;
  background: #e3e6ea;
  box-shadow: inset 2px 2px 4px #c1c6cd, inset -2px -2px 4px #ffffff;
  overflow: hidden;
}
.nm-player__bar span {
  display: block;
  width: 38%;
  height: 100%;
  background: #4338ca;
}
.nm-player__time {
  display: flex;
  justify-content: space-between;
  margin-top: 6px;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: #4b5260;
}
.nm-player__controls {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 22px;
  margin-top: 14px;
}
.nm-player__btn,
.nm-player__play {
  display: grid;
  place-items: center;
  padding: 0;
  border-radius: 50%;
  cursor: pointer;
}
.nm-player__btn {
  width: 52px;
  height: 52px;
  border: 1px solid #747c88;
  background: #e3e6ea;
  color: #262b33;
  box-shadow: 5px 5px 10px #c1c6cd, -5px -5px 10px #ffffff, inset 0 0 0 #c1c6cd, inset 0 0 0 #ffffff;
}
.nm-player__btn:active {
  box-shadow: 0 0 0 #c1c6cd, 0 0 0 #ffffff, inset 3px 3px 6px #c1c6cd, inset -3px -3px 6px #ffffff;
}
.nm-player__btn svg {
  width: 22px;
  height: 22px;
  fill: currentColor;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.nm-player__play {
  width: 70px;
  height: 70px;
  border: 0;
  background: #4338ca;
  color: #ffffff;
  box-shadow: 7px 7px 14px #c1c6cd, -7px -7px 14px #ffffff, inset 0 0 0 #2f2690, inset 0 0 0 #5b51e0;
}
.nm-player__play[aria-pressed="true"] {
  box-shadow: 2px 2px 4px #c1c6cd, -2px -2px 4px #ffffff, inset 4px 4px 8px #2f2690, inset -3px -3px 6px #5b51e0;
}
.nm-player__play svg {
  width: 28px;
  height: 28px;
  fill: currentColor;
}
.nm-player__icon-pause,
.nm-player__play[aria-pressed="true"] .nm-player__icon-play {
  display: none;
}
.nm-player__play[aria-pressed="true"] .nm-player__icon-pause {
  display: block;
}
.nm-player__btn:focus-visible,
.nm-player__play:focus-visible {
  outline: 3px solid #4338ca;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .nm-player__btn,
  .nm-player__play {
    transition: box-shadow .2s ease;
  }
}

Calculator keypad

  • CSS grid
  • :active press
  • aria-live output
0

A working four-function keypad. Every key is raised with a 3.40:1 border and presses in on :active, the operators use burnt orange text (5.03:1) and the equals key is a solid fill, so the key types read by color as well as position. The result is an output element with aria-live, and the math never touches eval.

View code
.nm-calc {
  display: grid;
  place-items: center;
  padding: 40px 16px;
  background: #e6e7e3;
  color: #2b2d27;
}
.nm-calc__body {
  width: min(100%, 320px);
  padding: 22px;
  border-radius: 32px;
  background: #e6e7e3;
  box-shadow: 12px 12px 26px #c4c5c0, -12px -12px 26px #ffffff;
}
.nm-calc__screen {
  margin-bottom: 20px;
  padding: 14px 18px;
  border: 1px solid #7a7c73;
  border-radius: 18px;
  background: #e6e7e3;
  text-align: right;
  box-shadow: inset 4px 4px 9px #c4c5c0, inset -4px -4px 9px #ffffff;
}
.nm-calc__expr {
  min-height: 20px;
  margin: 0;
  font-size: 14px;
  line-height: 20px;
  color: #55574f;
  font-variant-numeric: tabular-nums;
}
.nm-calc__value {
  display: block;
  overflow: hidden;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 34px;
  line-height: 1.2;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: #2b2d27;
}
.nm-calc__keys {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
}
.nm-calc__key {
  aspect-ratio: 1;
  min-height: 44px;
  padding: 0;
  border: 1px solid #7a7c73;
  border-radius: 16px;
  background: #e6e7e3;
  color: #2b2d27;
  font: inherit;
  font-size: 20px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 4px 4px 9px #c4c5c0, -4px -4px 9px #ffffff, inset 0 0 0 #c4c5c0, inset 0 0 0 #ffffff;
}
.nm-calc__key:active {
  box-shadow: 0 0 0 #c4c5c0, 0 0 0 #ffffff, inset 3px 3px 7px #c4c5c0, inset -3px -3px 7px #ffffff;
}
.nm-calc__key--fn {
  font-size: 16px;
}
.nm-calc__key--op {
  color: #9a4a0b;
  font-size: 24px;
}
.nm-calc__key--zero {
  grid-column: span 2;
  aspect-ratio: auto;
}
.nm-calc__key--eq {
  border-color: #9a4a0b;
  background: #9a4a0b;
  color: #ffffff;
  font-size: 24px;
  box-shadow: 4px 4px 9px #c4c5c0, -4px -4px 9px #ffffff, inset 0 0 0 #6f3407, inset 0 0 0 #b8601a;
}
.nm-calc__key--eq:active {
  box-shadow: 0 0 0 #c4c5c0, 0 0 0 #ffffff, inset 3px 3px 7px #6f3407, inset -3px -3px 7px #b8601a;
}
.nm-calc__key:focus-visible {
  outline: 3px solid #9a4a0b;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .nm-calc__key {
    transition: box-shadow .12s ease;
  }
}

Smart-home thermostat dial

  • SVG stroke-dasharray
  • pathLength
  • output aria-live

Living room

Heating to21.5°C Now 19.8°C

A raised dial with an inset face, and a three-quarter SVG arc that shows the setpoint between 10 and 30 degrees. The arc length comes from stroke-dasharray on a circle with pathLength="100", so the script only writes one number. The orange arc (5.2:1) and the large readout carry the value, and the buttons give keyboard and screen reader users the same control.

View code
.nm-thermo {
  display: grid;
  place-items: center;
  padding: 40px 16px;
  background: #e1e7ec;
  color: #22303b;
}
.nm-thermo__panel {
  display: grid;
  justify-items: center;
  gap: 18px;
}
.nm-thermo__room {
  margin: 0;
  font-weight: 700;
  font-size: 15px;
}
.nm-thermo__dial {
  position: relative;
  display: grid;
  place-items: center;
  width: 240px;
  height: 240px;
  border: 1px solid #737f8a;
  border-radius: 50%;
  background: #e1e7ec;
  box-shadow: 14px 14px 28px #bec6cd, -14px -14px 28px #ffffff;
}
.nm-thermo__ring {
  position: absolute;
  inset: 10px;
  width: calc(100% - 20px);
  height: calc(100% - 20px);
}
.nm-thermo__ring circle {
  fill: none;
  stroke-width: 9;
  stroke-linecap: round;
}
.nm-thermo__track {
  stroke: #c3ccd4;
}
.nm-thermo__arc {
  stroke: #a8380a;
}
.nm-thermo__face {
  display: grid;
  place-items: center;
  align-content: center;
  width: 150px;
  height: 150px;
  border-radius: 50%;
  background: #e1e7ec;
  box-shadow: inset 6px 6px 12px #bec6cd, inset -6px -6px 12px #ffffff;
}
.nm-thermo__mode,
.nm-thermo__now {
  font-size: 13px;
  color: #4a5865;
}
.nm-thermo__mode {
  color: #a8380a;
  font-weight: 600;
}
.nm-thermo__value {
  font-family: "Fraunces", Georgia, serif;
  font-size: 40px;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
  color: #22303b;
}
.nm-thermo__buttons {
  display: flex;
  gap: 40px;
}
.nm-thermo__btn {
  width: 56px;
  height: 56px;
  padding: 0;
  border: 1px solid #737f8a;
  border-radius: 50%;
  background: #e1e7ec;
  color: #22303b;
  font: inherit;
  font-size: 26px;
  line-height: 1;
  cursor: pointer;
  box-shadow: 5px 5px 10px #bec6cd, -5px -5px 10px #ffffff, inset 0 0 0 #bec6cd, inset 0 0 0 #ffffff;
}
.nm-thermo__btn:active {
  box-shadow: 0 0 0 #bec6cd, 0 0 0 #ffffff, inset 3px 3px 6px #bec6cd, inset -3px -3px 6px #ffffff;
}
.nm-thermo__btn:focus-visible {
  outline: 3px solid #a8380a;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .nm-thermo__arc {
    transition: stroke-dasharray .3s ease;
  }
  .nm-thermo__btn {
    transition: box-shadow .15s ease;
  }
}

Sign-in card with inset fields

  • inset inputs
  • show password toggle
  • preventDefault

Inset fields inside a raised card, with real labels above them and a 3.61:1 border so each field is findable without the shadow. The primary button is a solid berry fill, not a soft surface, because a call to action needs contrast more than mood. The demo form never submits.

View code
.nm-login {
  display: grid;
  place-items: center;
  padding: 40px 16px;
  background: #efe5e8;
  color: #2f2226;
}
.nm-login__card {
  width: min(100%, 340px);
  margin: 0;
  padding: 28px 24px 22px;
  border-radius: 28px;
  background: #efe5e8;
  box-shadow: 12px 12px 26px #cfc2c6, -12px -12px 26px #ffffff;
}
.nm-login__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: 0;
  color: #2f2226;
}
.nm-login__sub {
  margin: 4px 0 20px;
  font-size: 14px;
  color: #6a555c;
}
.nm-login__label {
  display: block;
  margin: 14px 0 6px 4px;
  font-size: 14px;
  font-weight: 600;
}
.nm-login__input {
  display: block;
  box-sizing: border-box;
  width: 100%;
  height: 46px;
  padding: 0 14px;
  border: 1px solid #8a7179;
  border-radius: 14px;
  background: #efe5e8;
  color: #2f2226;
  font: inherit;
  font-size: 16px;
  box-shadow: inset 3px 3px 7px #cfc2c6, inset -3px -3px 7px #ffffff;
}
.nm-login__input::placeholder {
  color: #6a555c;
  opacity: 1;
}
.nm-login__input:focus-visible {
  outline: 3px solid #9f1239;
  outline-offset: 2px;
}
.nm-login__pass {
  position: relative;
}
.nm-login__pass .nm-login__input {
  padding-right: 76px;
}
.nm-login__show {
  position: absolute;
  top: 5px;
  right: 5px;
  min-width: 60px;
  height: 36px;
  padding: 0 10px;
  border: 1px solid #8a7179;
  border-radius: 10px;
  background: #efe5e8;
  color: #2f2226;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 2px 2px 5px #cfc2c6, -2px -2px 5px #ffffff;
}
.nm-login__show[aria-pressed="true"] {
  border-color: #9f1239;
  color: #9f1239;
  box-shadow: inset 2px 2px 4px #cfc2c6, inset -2px -2px 4px #ffffff;
}
.nm-login__show:focus-visible {
  outline: 3px solid #9f1239;
  outline-offset: 2px;
}
.nm-login__submit {
  width: 100%;
  height: 50px;
  margin-top: 24px;
  border: 0;
  border-radius: 14px;
  background: #9f1239;
  color: #ffffff;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 6px 6px 12px #cfc2c6, -6px -6px 12px #ffffff;
}
.nm-login__submit:active {
  box-shadow: inset 3px 3px 7px #6e0c27;
}
.nm-login__submit:focus-visible {
  outline: 3px solid #2f2226;
  outline-offset: 3px;
}
.nm-login__status {
  min-height: 1.5em;
  margin: 12px 0 0;
  font-size: 13px;
  text-align: center;
  color: #2f2226;
}

Progress ring in an inset well

  • role="progressbar"
  • SVG ring
  • stroke-dasharray
68%of goal

47,600 of 70,000 steps

The ring lives in an inset groove of a raised disc, and its moss stroke (4.18:1 against the groove, 5.75:1 against the panel) is what shows progress. The wrapper is a progressbar with aria-valuenow and a text alternative, and the button logs steps until the goal is met.

View code
.nm-ring {
  display: grid;
  place-items: center;
  padding: 40px 16px;
  background: #e5e9e1;
  color: #263021;
}
.nm-ring__card {
  display: grid;
  justify-items: center;
  width: min(100%, 320px);
  padding: 26px 22px;
  border-radius: 28px;
  background: #e5e9e1;
  text-align: center;
  box-shadow: 12px 12px 26px #c3c9bd, -12px -12px 26px #ffffff;
}
.nm-ring__disc {
  position: relative;
  display: grid;
  place-items: center;
  width: 180px;
  height: 180px;
  border-radius: 50%;
  background: #e5e9e1;
  box-shadow: 8px 8px 16px #c3c9bd, -8px -8px 16px #ffffff;
}
.nm-ring__disc svg {
  position: absolute;
  inset: 10px;
  width: 160px;
  height: 160px;
}
.nm-ring__disc circle {
  fill: none;
  stroke-width: 14;
  stroke-linecap: round;
}
.nm-ring__track {
  stroke: #c3c9bd;
}
.nm-ring__bar {
  stroke: #3f6212;
}
.nm-ring__label {
  display: grid;
  line-height: 1.1;
}
.nm-ring__label strong {
  font-family: "Fraunces", Georgia, serif;
  font-size: 36px;
  color: #263021;
}
.nm-ring__label span {
  font-size: 13px;
  color: #4a5444;
}
.nm-ring__count {
  margin: 20px 0 16px;
  font-size: 15px;
  font-variant-numeric: tabular-nums;
}
.nm-ring__btn {
  min-height: 46px;
  padding: 0 20px;
  border: 1px solid #73806a;
  border-radius: 14px;
  background: #e5e9e1;
  color: #263021;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 5px 5px 10px #c3c9bd, -5px -5px 10px #ffffff;
}
.nm-ring__btn:active {
  box-shadow: inset 3px 3px 6px #c3c9bd, inset -3px -3px 6px #ffffff;
}
.nm-ring__btn[aria-disabled="true"] {
  border-color: #3f6212;
  color: #3f6212;
  box-shadow: none;
  cursor: default;
}
.nm-ring__btn:focus-visible {
  outline: 3px solid #3f6212;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .nm-ring__bar {
    transition: stroke-dasharray .5s ease;
  }
}

Tab bar in a phone app

  • role="tablist"
  • roving tabindex
  • inset selected tab

Good morning, Ana

3 of 4 habits done today

  • Drink waterDone
  • Read 20 pagesDone
  • StretchDone
  • Walk 30 minutesTo do

A bottom tab bar for a habit app. The selected tab sinks into the bar and turns blue with a short bar under its label, so the current screen is clear from color and shape. It follows the ARIA tabs pattern: arrow keys move between tabs, Home and End jump to the ends, and only the selected tab is in the tab order.

View code
.nm-tabs {
  position: relative;
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background: #e4e9f2;
  color: #222b3a;
}
.nm-tabs__screen {
  flex: 1;
  min-height: 0;
  overflow: auto;
  padding: 22px 20px 12px;
}
.nm-tabs__panel:focus-visible {
  outline: 3px solid #2f55c8;
  outline-offset: 4px;
  border-radius: 8px;
}
.nm-tabs__hello {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  line-height: 1.2;
  color: #222b3a;
}
.nm-tabs__sub {
  margin: 4px 0 18px;
  font-size: 14px;
  color: #4a5568;
}
.nm-tabs__list {
  display: grid;
  gap: 14px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.nm-tabs__list li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  min-height: 52px;
  padding: 0 16px;
  border-radius: 16px;
  background: #e4e9f2;
  font-size: 15px;
  box-shadow: 5px 5px 10px #c2c8d2, -5px -5px 10px #ffffff;
}
.nm-tabs__list span {
  font-size: 13px;
  font-weight: 600;
  color: #4a5568;
}
.nm-tabs__list .is-done span {
  color: #2f55c8;
}
.nm-tabs__entry {
  margin: 0;
  padding: 16px;
  border-radius: 16px;
  font-size: 15px;
  line-height: 1.6;
  box-shadow: inset 4px 4px 8px #c2c8d2, inset -4px -4px 8px #ffffff;
}
.nm-tabs__bar {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 8px;
  margin: 0 12px 12px;
  padding: 8px;
  border: 1px solid #737f94;
  border-radius: 22px;
  background: #e4e9f2;
  box-shadow: 6px 6px 14px #c2c8d2, -6px -6px 14px #ffffff;
}
.nm-tabs__tab {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 2px;
  min-height: 56px;
  padding: 6px 0 8px;
  border: 1px solid transparent;
  border-radius: 16px;
  background: #e4e9f2;
  color: #4a5568;
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
}
.nm-tabs__tab svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.nm-tabs__tab[aria-selected="true"] {
  border-color: #2f55c8;
  color: #2f55c8;
  box-shadow: inset 3px 3px 6px #c2c8d2, inset -3px -3px 6px #ffffff;
}
.nm-tabs__tab[aria-selected="true"]::after {
  content: "";
  position: absolute;
  bottom: 4px;
  width: 18px;
  height: 3px;
  border-radius: 3px;
  background: #2f55c8;
}
.nm-tabs__tab:focus-visible {
  outline: 3px solid #2f55c8;
  outline-offset: 2px;
}

Dashboard stat cards

  • auto-fit grid
  • SVG sparklines
  • aria-pressed range

Store overview

Revenue

$48.2k

▲ 12.4%

Orders

1,284

▲ 9.7%

Refund rate

1.8%

▼ 0.2 pts

Avg. order

$37.54

▲ 2.5%

Four raised cards with an inset sparkline well each, and a range switch that swaps the figures. Trend chips use colored text with an arrow and a sign, so good or bad never rests on hue alone. This is the right density ceiling for the style: a few large numbers, no tables.

View code
.nm-stats {
  padding: 32px clamp(16px, 4vw, 36px) 36px;
  background: #e6e9ee;
  color: #232a35;
  container-type: inline-size;
}
.nm-stats__top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: 14px;
  margin-bottom: 24px;
}
.nm-stats__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  color: #232a35;
}
.nm-stats__range {
  display: flex;
  gap: 6px;
  padding: 5px;
  border: 1px solid #757d8a;
  border-radius: 14px;
  box-shadow: inset 3px 3px 6px #c4c8cf, inset -3px -3px 6px #ffffff;
}
.nm-stats__range button {
  min-width: 48px;
  min-height: 36px;
  padding: 0 10px;
  border: 1px solid transparent;
  border-radius: 10px;
  background: #e6e9ee;
  color: #4a5362;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.nm-stats__range button[aria-pressed="true"] {
  border-color: #2f55c8;
  color: #2f55c8;
  box-shadow: 3px 3px 6px #c4c8cf, -3px -3px 6px #ffffff;
}
.nm-stats__range button:focus-visible {
  outline: 3px solid #2f55c8;
  outline-offset: 2px;
}
.nm-stats__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr));
  gap: 20px;
}
.nm-stats__card {
  min-width: 0;
  padding: 18px 18px 16px;
  border-radius: 20px;
  background: #e6e9ee;
  box-shadow: 8px 8px 18px #c4c8cf, -8px -8px 18px #ffffff;
}
.nm-stats__label {
  margin: 0;
  font-size: 13px;
  font-weight: 600;
  color: #4a5362;
}
.nm-stats__value {
  margin: 4px 0 6px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
  color: #232a35;
}
.nm-stats__chip {
  display: inline-block;
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
}
.nm-stats__chip[data-good="true"] {
  color: #1d6b48;
}
.nm-stats__chip[data-good="false"] {
  color: #a8380a;
}
.nm-stats__card svg {
  display: block;
  width: 100%;
  height: 40px;
  padding: 6px 8px;
  box-sizing: border-box;
  border-radius: 12px;
  box-shadow: inset 3px 3px 6px #c4c8cf, inset -3px -3px 6px #ffffff;
}
.nm-stats__card polyline {
  fill: none;
  stroke: #2f55c8;
  stroke-width: 2.5;
  stroke-linejoin: round;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
@container (max-width: 360px) {
  .nm-stats__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 14px;
  }
  .nm-stats__card {
    padding: 14px 12px 12px;
  }
  .nm-stats__value {
    font-size: 22px;
  }
}

Contrast, dark mode and forced colors

Where neumorphism usually breaks. A failing and a fixed control side by side with measured ratios, a dark theme that keeps its boundaries, and the fallback for forced colors mode, where every shadow is removed.

Failing vs fixed, with measured ratios

  • WCAG 1.4.11
  • border at 3:1
  • state by fill

Shadows only

Edge
1.46:1
On state
1.46:1
Label
9.89:1

Border and fill

Edge
3.70:1
On state
5.09:1
Label
9.89:1

Left is the textbook recipe: #e0e5ec surface, #babecc and white shadows. Its edge measures 1.46:1 (dark shadow) and 1.26:1 (highlight) against the page, and the on state is only a deeper shadow, so both the boundary and the state fail the 3:1 of WCAG 1.4.11. Right keeps the same shadows and adds a #6b7488 border (3.70:1) and a blue on fill (5.09:1 against the page, white label 6.45:1). Ratios were calculated with the WCAG relative luminance formula.

View code
.nm-fix {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
  gap: 28px;
  padding: 36px clamp(16px, 4vw, 40px);
  background: #e0e5ec;
  color: #2d3440;
}
.nm-fix__side {
  display: grid;
  justify-items: center;
  align-content: start;
  gap: 18px;
}
.nm-fix__side + .nm-fix__side {
  border-left: 1px dashed #767f91;
}
.nm-fix__verdict {
  margin: 0;
  font-weight: 700;
  font-size: 15px;
}
.nm-fix__verdict::before {
  display: inline-block;
  margin-right: 8px;
  font-size: 13px;
  padding: 2px 8px;
  border-radius: 6px;
  color: #ffffff;
}
.nm-fix__verdict--fail::before {
  content: "Fails";
  background: #a3261d;
}
.nm-fix__verdict--pass::before {
  content: "Passes";
  background: #1d6b48;
}
.nm-fix__btn {
  width: min(100%, 210px);
  min-height: 52px;
  padding: 0 18px;
  border-radius: 16px;
  background: #e0e5ec;
  color: #2d3440;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 6px 6px 12px #babecc, -6px -6px 12px #ffffff, inset 0 0 0 #babecc, inset 0 0 0 #ffffff;
}
.nm-fix__btn--fail {
  border: 0;
}
.nm-fix__btn--fail[aria-pressed="true"] {
  box-shadow: 0 0 0 #babecc, 0 0 0 #ffffff, inset 5px 5px 10px #babecc, inset -5px -5px 10px #ffffff;
}
.nm-fix__btn--pass {
  border: 1px solid #6b7488;
}
.nm-fix__btn--pass[aria-pressed="true"] {
  border-color: #2f55c8;
  background: #2f55c8;
  color: #ffffff;
  box-shadow: 0 0 0 #babecc, 0 0 0 #ffffff, inset 4px 4px 8px #23409a, inset -3px -3px 6px #4a6fe0;
}
.nm-fix__btn--pass[aria-pressed="true"]::before {
  content: "\2713\00a0";
}
.nm-fix__btn:focus-visible {
  outline: 3px solid #2f55c8;
  outline-offset: 4px;
}
.nm-fix__ratios {
  display: flex;
  gap: 16px;
  margin: 4px 0 0;
  font-size: 13px;
  text-align: center;
}
.nm-fix__ratios dt {
  color: #4a5568;
}
.nm-fix__ratios dd {
  margin: 2px 0 0;
  font-family: ui-monospace, Consolas, monospace;
  font-weight: 700;
  color: #2d3440;
}
@media (max-width: 600px) {
  .nm-fix__side + .nm-fix__side {
    border-left: 0;
    border-top: 1px dashed #767f91;
    padding-top: 24px;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .nm-fix__btn {
    transition: box-shadow .2s ease, background-color .2s ease, color .2s ease;
  }
}

Dark neumorphism ride panel

  • dark shadow pair
  • radio segments
  • teal state color

24 km/h

Battery 78% · 52 km left
Assist mode

An e-bike control panel on #2a2e35. The shadow pair is #1e2126 and #363b44, which measure only 1.18:1 and 1.21:1, so dark mode is not a contrast fix on its own. The 3.67:1 border carries every boundary, and the selected mode and the light switch turn teal (9.21:1). Assist modes are native radios, so arrow keys work for free.

View code
.nm-dark {
  display: grid;
  place-items: center;
  padding: 40px 16px;
  background: #2a2e35;
  color: #e6e9ef;
}
.nm-dark__panel {
  display: grid;
  gap: 22px;
  width: min(100%, 380px);
  padding: 24px 22px;
  border: 1px solid #7d8594;
  border-radius: 28px;
  background: #2a2e35;
  box-shadow: 12px 12px 24px #1e2126, -12px -12px 24px #363b44;
}
.nm-dark__top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: end;
  gap: 12px;
}
.nm-dark__speed {
  margin: 0;
  font-size: 15px;
  color: #a4adbb;
}
.nm-dark__speed strong {
  font-family: "Fraunces", Georgia, serif;
  font-size: 48px;
  line-height: 1;
  font-weight: 600;
  color: #e6e9ef;
}
.nm-dark__battery {
  display: grid;
  gap: 6px;
  font-size: 13px;
  color: #a4adbb;
}
.nm-dark__cell {
  height: 10px;
  border: 1px solid #7d8594;
  border-radius: 999px;
  box-shadow: inset 2px 2px 4px #1e2126, inset -2px -2px 4px #363b44;
  overflow: hidden;
}
.nm-dark__cell span {
  display: block;
  width: 78%;
  height: 100%;
  background: #5eead4;
}
.nm-dark__modes {
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}
.nm-dark__modes legend {
  margin-bottom: 10px;
  padding: 0;
  font-size: 14px;
  font-weight: 600;
  color: #e6e9ef;
}
.nm-dark__seg {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 8px;
}
.nm-dark__seg label {
  position: relative;
  cursor: pointer;
}
.nm-dark__seg input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.nm-dark__seg span {
  display: grid;
  place-items: center;
  min-height: 46px;
  border: 1px solid #7d8594;
  border-radius: 14px;
  font-size: 14px;
  font-weight: 600;
  color: #e6e9ef;
  box-shadow: 4px 4px 8px #1e2126, -4px -4px 8px #363b44;
}
.nm-dark__seg input:checked + span {
  border-color: #5eead4;
  color: #5eead4;
  box-shadow: inset 3px 3px 6px #1e2126, inset -3px -3px 6px #363b44;
}
.nm-dark__seg input:focus-visible + span {
  outline: 3px solid #5eead4;
  outline-offset: 3px;
}
.nm-dark__light {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 50px;
  border: 1px solid #7d8594;
  border-radius: 16px;
  background: #2a2e35;
  color: #e6e9ef;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 5px 5px 10px #1e2126, -5px -5px 10px #363b44;
}
.nm-dark__light svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.nm-dark__light::after {
  content: "Off";
  margin-left: auto;
  font-size: 13px;
  color: #a4adbb;
}
.nm-dark__light {
  padding: 0 18px;
}
.nm-dark__light[aria-pressed="true"] {
  border-color: #5eead4;
  color: #5eead4;
  box-shadow: inset 3px 3px 6px #1e2126, inset -3px -3px 6px #363b44;
}
.nm-dark__light[aria-pressed="true"] svg {
  fill: currentColor;
}
.nm-dark__light[aria-pressed="true"]::after {
  content: "On";
  color: #5eead4;
}
.nm-dark__light:focus-visible {
  outline: 3px solid #5eead4;
  outline-offset: 3px;
}

Forced colors fallback

  • @media (forced-colors: active)
  • transparent border
  • forced-color-adjust

No border

Transparent border

Forced colors mode sets box-shadow to none, so a shadow-only button becomes bare text. A transparent border is invisible normally, but the system repaints it in a visible color, and the on state is mapped to Highlight inside the media query. Press Simulate to preview a dark contrast theme on both columns; the real forced colors rules are in the CSS.

View code
.nm-fc {
  display: grid;
  justify-items: center;
  gap: 22px;
  padding: 36px 16px;
  background: #e3e7ea;
  color: #2a3038;
}
.nm-fc__screen {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
  gap: 26px;
  width: min(100%, 520px);
  padding: 24px;
  border-radius: 24px;
  background: #e3e7ea;
}
.nm-fc__col {
  display: grid;
  justify-items: center;
  align-content: start;
  gap: 16px;
}
.nm-fc__label {
  margin: 0;
  font-size: 14px;
  font-weight: 700;
}
.nm-fc__btn {
  width: min(100%, 200px);
  min-height: 50px;
  padding: 0 16px;
  border-radius: 16px;
  background: #e3e7ea;
  color: #2a3038;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 6px 6px 12px #c1c6cb, -6px -6px 12px #ffffff;
}
.nm-fc__btn--bare {
  border: 0;
}
.nm-fc__btn--safe {
  border: 2px solid transparent;
}
.nm-fc__btn[aria-pressed="true"] {
  background: #2f55c8;
  color: #ffffff;
  box-shadow: inset 4px 4px 8px #23409a, inset -3px -3px 6px #4a6fe0;
}
.nm-fc__btn:focus-visible,
.nm-fc__sim:focus-visible {
  outline: 3px solid #2f55c8;
  outline-offset: 4px;
}
.nm-fc__sim {
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid #6d7782;
  border-radius: 999px;
  background: #e3e7ea;
  color: #2a3038;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 4px 4px 8px #c1c6cb, -4px -4px 8px #ffffff;
}
.nm-fc__sim[aria-pressed="true"] {
  border-color: #2a3038;
  background: #2a3038;
  color: #ffffff;
  box-shadow: none;
}
.nm-fc.is-sim .nm-fc__screen {
  background: #000000;
  color: #ffffff;
}
.nm-fc.is-sim .nm-fc__btn {
  background: #000000;
  color: #ffffff;
  box-shadow: none;
}
.nm-fc.is-sim .nm-fc__btn--safe {
  border-color: #ffffff;
}
.nm-fc.is-sim .nm-fc__btn--safe[aria-pressed="true"] {
  border-color: #1aebff;
  background: #1aebff;
  color: #000000;
}
.nm-fc.is-sim .nm-fc__btn:focus-visible {
  outline-color: #ffff00;
}
@media (forced-colors: active) {
  .nm-fc__btn--safe[aria-pressed="true"] {
    forced-color-adjust: none;
    border-color: Highlight;
    background: Highlight;
    color: HighlightText;
  }
}

What is CSS neumorphism?

Take the shadows away and you’re left with a flat grey box on a flat grey page. That’s the whole trick, really. The shadows carry all the depth.

The name blends “neo” and “skeuomorphism” (Built In). You will also see it written as neomorphism or soft UI.

It’s built from CSS alone, no images or JavaScript, and the two opposing shadows imitate one light source hitting a molded surface. The one hard requirement is that the element and its parent share a background color.

A standard drop shadow lifts an element above the page. Neumorphism keeps it inside the page instead, which is why it works as a surface treatment across a whole user interface and not just on a single component.

You only need plain CSS for it. box-shadow does most of the work, with border-radius and background playing along.

Neumorphism compared with skeuomorphism, flat design and glassmorphism

Most people place neumorphism somewhere between skeuomorphism and flat design. It holds on to some physical depth while staying close to minimal UI, and CSS-Tricks places it in the middle, between Apple’s old skeuomorphic designs and today’s flat interfaces.

That puts neumorphism as a design style in an odd spot, borrowing depth from one parent and restraint from the other.

StyleDepth cueBackground needContrast profile
SkeuomorphismTextures, gloss, realistic detailAnyStrong, often heavy
Flat designColor blocks, no shadowsAnyGood, weak click cues
NeumorphismPaired light and dark shadowsMid-tone, solid, same as the elementLow, usually under 3:1
GlassmorphismBlur and translucencyBusy backdrop to blurShifts with the backdrop

The frosted-glass look called glassmorphism needs a colorful backdrop to blur, which makes it a poor match for neumorphism and its single solid surface. These CSS glassmorphism examples show the other style built properly.

Origin and timeline

Apple showed off a flat iOS 7 at WWDC in 2013 (TechCrunch, 2013), and flat design more or less owned the mainstream for the rest of the decade. Then, in late 2019, Alexander Plyuto posted a banking app concept on Dribbble and the shot went viral (Built In, OpenReplay).

OpenReplay calls soft UI the most trending digital design pattern of 2020.

In April 2022, CRED, an app that had adopted a neumorphic look, moved over to its NeoPOP design system (UX Planet, 2022).

Nobody agrees on who named it. Wikipedia says Jason Kelly. Built In and UX Planet trace it to a comment thread under a Michal Malewicz article, and SVGator gives Malewicz the credit directly. I’d treat any single attribution as unsettled.

How box-shadow creates the neumorphic effect

One shadow goes dark and sits offset away from the imagined light. The other goes light and sits offset toward it. Stack them on an element whose background matches its parent and you get a raised surface. Flip both to inset and the surface reads as pressed.

body { background: #e0e5ec; }

.card {
  background: #e0e5ec;
  border-radius: 16px;
  box-shadow: 8px 8px 16px #babecc, -8px -8px 16px #ffffff;
}

Shadow syntax and parameters

The first two lengths set the x and y offsets. A negative x moves the shadow left, and a negative y puts it above the element. Blur radius comes next, where a larger value gives a bigger, lighter shadow and negative values are invalid.

Spread is optional. Positive values grow the shadow, negative values shrink it. Then there’s inset, another optional keyword, which moves the shadow inside the box and clips it to the padding box.

According to MDN Web Docs (2026), the first shadow in a comma-separated list sits on top. Box-shadow also never changes box dimensions, so a pressed state does not shift the layout around it.

Tuning these numbers by eye is slow, and a box shadow generator shows both layers live. For shadows beyond the neumorphic pair, see these CSS shadow effects.

Choosing the base color and shades

The base color decides whether the effect works at all.

Pick a mid-tone, flat, solid background and give the element the exact same color. #e0e5ec is what coddy.tech calls the canonical neumorphic grey.

Derive the two shadows by shifting that base, a few steps darker for the shadow and near white for the highlight. Pure black and pure white shadows make the card look cheap, per coddy.tech’s generator guide.

A white page makes the white highlight vanish, since there’s nothing to contrast against. Change the offsets between elements and the light source seems to wander around the page, and shades far from the base color make the card look pasted on instead of molded.

Corners count too. A box-shadow follows border-radius, so the radius gives the shadow its soft outline (MDN Web Docs, 2026), and a border radius generator settles the value quickly.

How to build a neumorphic button in CSS

Same background as the page, a raised two-layer box-shadow, then inset shadows on :active for the pressed state. That’s the core of it. Most raised-to-inset snippets you’ll find skip the transition fix though, so watch step 5.

  1. Set the same background on the page and the button, for example #e0e5ec.
  2. Remove the native border and round the corners with border-radius: 12px.
  3. Add the raised state: 8px 8px 16px #babecc and -8px -8px 16px #ffffff.
  4. Build the pressed state on :active by moving both shadows inside with the inset keyword.
  5. Pad each state with zero-size shadows so both lists hold four entries in the same order. That lets the transition animate.
  6. Add a visible :focus-visible outline, because the shadow swap alone is a weak state signal.
.neu-btn {
  background: #e0e5ec;
  border: none;
  border-radius: 12px;
  padding: 14px 28px;
  color: #2d3440;
  box-shadow:
  8px 8px 16px #babecc,
  -8px -8px 16px #ffffff,
  inset 0 0 0 #babecc,
  inset 0 0 0 #ffffff;
  transition: box-shadow 0.2s ease;
}

.neu-btn:active {
  box-shadow:
  0 0 0 #babecc,
  0 0 0 #ffffff,
  inset 8px 8px 16px #babecc,
  inset -8px -8px 16px #ffffff;
}

.neu-btn:focus-visible {
  outline: 2px solid #2d3440;
  outline-offset: 4px;
}

Why the padding step matters

MDN Web Docs (2026) states that shadows are paired in order during a transition. If any pair differs in its inset setting, the whole list skips the animation and jumps to the new values.

A plain outer-to-inset swap therefore snaps instead of easing. Matching the list shape fixes it.

Why the focus step matters

WCAG 2.2 SC 1.4.11 requires at least 3:1 contrast against adjacent colors for visual information that identifies components and states (W3C). A raised-to-pressed swap on a matching background typically falls short of that.

Keep :hover for something subtler, like a slightly larger blur. A hover that fakes a press tells mouse users the click already happened. More options live in these CSS button hover effects.

If you need a lot of variants, a CSS button generator speeds up the first draft before you apply the shadow pairs by hand.

Neumorphic states and the components that suit them

A neumorphic element has to handle default, hover, focus, pressed and disabled states. Shadows do most of that work, though never all of it.

StateShadow behaviorExtra signal needed
DefaultRaised pair outside the elementVisible text label or icon
HoverSmaller offset and blur, slightly flatterPointer cursor
Focus-visibleUnchangedSolid outline in a dark color
Pressed or checkedOuter pair collapses, inset pair appearsAccent color or aria-pressed
DisabledNo shadow, flat surfaceNative disabled attribute

Toggles, cards and sliders

Toggles suit this style better than anything else. The track sits inset, the knob raised, and the pressed idea is already built in.

If you want a deeper walkthrough of the markup behind switches, the guide on how to build a CSS toggle switch covers the non-neumorphic baseline.

.neu-toggle {
  appearance: none;
  position: relative;
  width: 56px;
  height: 30px;
  border-radius: 15px;
  background: #e0e5ec;
  box-shadow: inset 4px 4px 8px #babecc, inset -4px -4px 8px #ffffff;
  cursor: pointer;
}
.neu-toggle::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: #e0e5ec;
  box-shadow: 3px 3px 6px #babecc, -3px -3px 6px #ffffff;
  transition: transform 0.2s ease;
}
.neu-toggle:checked::after {
  transform: translateX(26px);
  background: #2f55c8;
}
.neu-toggle:focus-visible {
  outline: 2px solid #2d3440;
  outline-offset: 4px;
}

Only transform animates here. The shadows stay fixed, which keeps the transition cheap to paint (more on that in the last section).

The checked knob turns saturated blue, so the on state does not depend on shadow geometry.

Convex and concave surfaces

A flat shadow pair reads as a raised plate. Put a slight gradient on top and the surface looks curved.

Convex surfaces go from light to dark starting at the top left, and concave ones flip that. Keep both gradient stops close to the base color, otherwise the element stops matching its parent. A tool like the CSS gradient generator helps you find stops that stay inside that range.

.neu-card--convex {
  background: linear-gradient(145deg, #f0f5fc, #cacfd6);
  box-shadow: 8px 8px 16px #babecc, -8px -8px 16px #ffffff;
}
.neu-card--concave {
  background: linear-gradient(145deg, #cacfd6, #f0f5fc);
}

Components that work and components that struggle

Toggles, buttons with clear labels, music player controls, knobs and cards with a single purpose all suit it. Inputs, selects and progress bars don’t, because users need to tell empty from filled and open from closed at a glance.

Sliders land in the middle. The track works inset, but the filled portion needs a color, not just depth.

CSS-Tricks warns against stateful inputs for this reason, and it holds up in practice. A progress bar built only from shadows tells you very little about progress.

Is CSS neumorphism accessible?

Not by default. As usually drawn, it fails the non-text contrast requirement from WCAG 2.2 wherever the shadow is the only cue for a control or its state, and no amount of careful coding fixes that without changing the design.

Key contrast figures

The base #e0e5ec against the dark shadow #babecc comes to 1.46:1, calculated with the WCAG relative luminance formula. In the Plus Addons contrast testing, the best case reached 1.86:1, which is still far below what a control boundary needs.

Body text is a different story. SC 1.4.3 asks for 4.5:1, and dark text such as #2d3440 on the same base clears it with room to spare.

A 1px border in #767f91 on that base gives 3.18:1, which passes the non-text threshold.

What WCAG actually measures

The Understanding document for SC 1.4.11 says to evaluate the colors in the markup and stylesheets, not the pixels on screen, because anti-aliasing can render thin lines and shapes fainter than their CSS color. For a blurred shadow that means the declared shadow color, which is the most generous reading, and it still fails.

The same document says a visible boundary is required only when nothing else identifies the control. A clear text label or icon can carry identification, which is a different route from the one most fixes take. Anyone auditing a design should check color contrast on the label and the boundary separately, then treat the focus indicator as its own step.

Fixes that work

The simplest repair is a border in a color that passes against the base, and Boundev recommends exactly that. Plus Addons takes another route for most controls. It points to text labels and state indicators that do not rely on shadow, such as a color fill or a check glyph, and keeps a real border for form inputs. Both sources are right, they just describe different routes.

Either way, use a strong :focus-visible outline that does not rely on shadow, and show state with an accent color, text or icon instead of depth alone.

Pick one route per component and apply it consistently. Mixing them across a page makes the interface look uneven.

Testing the result

The WebAIM Contrast Checker now reports a result for graphical objects and UI components, so it handles the border and accent checks. It cannot judge whether a shadow-only control is identifiable.

The Texas A&M IT accessibility guide makes the same point, that graphical objects still need manual verification. If your palette fails, an accessible color palette generator gives you candidate border and accent colors faster than trial and error.

Dark mode, design tokens and generators for neumorphic shadows

Dark neumorphism works, but it needs its own shadow values. Reusing the light palette with a darker base produces a muddy result.

Building a dark theme

Make the shadow darker than the base. The highlight should be lighter than the base, though not white, because a white highlight on a dark surface looks like a glow.

Boundev describes a palette of #2d2d2d for the base, #3a3a3a for the highlight and #1a1a1a for the shadow. It also argues that dark mode often gives better contrast than light mode.

Plus Addons measured the opposite. Its dark themes scored lower than its light ones. The numbers support Plus Addons: #2d2d2d against #1a1a1a computes to 1.26:1, which is no improvement on the light palette. Treat dark mode as a visual preference, not an accessibility fix.

prefers-color-scheme is Baseline widely available, so the media query is safe to use without a fallback.

Design tokens in plain CSS

Base, dark shade, light shade and text color all go into custom properties, and components then reference a single shadow variable.

:root {
  --neu-bg: #e0e5ec;
  --neu-dark: #babecc;
  --neu-light: #ffffff;
  --neu-text: #2d3440;
  --neu-shadow: 8px 8px 16px var(--neu-dark), -8px -8px 16px var(--neu-light);
}
@media (prefers-color-scheme: dark) {
  :root {
    --neu-bg: #2d2d2d;
    --neu-dark: #1a1a1a;
    --neu-light: #3a3a3a;
    --neu-text: #e6e6e6;
  }
}
.neu-card {
  background: var(--neu-bg);
  color: var(--neu-text);
  box-shadow: var(--neu-shadow);
}

Tailwind and Figma

Tailwind v4 reads theme variables named --shadow-* and turns each one into a utility. Teams already working with Tailwind CSS can register the neumorphic pair once and reuse it everywhere.

@theme {
  --shadow-neu: 8px 8px 16px #babecc, -8px -8px 16px #ffffff;
  --shadow-neu-inset: inset 8px 8px 16px #babecc, inset -8px -8px 16px #ffffff;
}

That gives you shadow-neu in markup. For a one-off value, shadow-(--neu-shadow) pulls from a custom property and shadow-[...] takes a raw value. Tailwind’s own inset-shadow-* utilities also exist, but the documented examples each use a single shadow, so the paired inset look is safer as the custom token above.

On the design side, keep the Figma names identical to the CSS names. The Figma variables guide explains how to store the base and shade colors as variables so a theme switch updates every frame.

Using generators without trusting them

Many generators output only the raised state. They rarely pad the pressed state with matching zero-size pairs, so the transition snaps, as covered in the button section.

Use them for a first draft of the numbers, then check the output against the same-background rule and your contrast figures.

When neumorphism does not fit

Skip it when users have to scan a lot or act fast. The style trades legibility for atmosphere, and some interfaces can’t afford that trade.

Strengths and limits at a glance

StrengthsLimits
Cohesive, calm look on simple control panelsLow contrast between element and background
Pure CSS, no images or extra assetsNeeds the element and parent to share one color
Natural pressed states using inset shadowsShadow-only state is invisible to many users
Easy to theme with custom propertiesShadows disappear in forced colors mode

Dashboards and dense interfaces

Sources disagree on dashboards. Boundev rates data-dense ones as a poor fit, while Plus Addons lists thermostat dashboards as a good one.

Both can be true. A dashboard made of a few large controls suits the style. One packed with charts and status-colored tables does not, because every added element competes for the same faint depth cues.

Call-to-action elements

Primary actions need to stand out. A well-built call-to-action relies on contrast and color, and a raised surface that matches its background does neither.

Use an accent fill for the main action and keep neumorphism for secondary controls. Or drop it for that button entirely.

Performance on long pages

Boundev suggests promoting neumorphic elements that actively animate to their own compositing layer with will-change, and warns against applying it to every element. The web.dev animation guidance is more cautious still. Shadows are expensive to paint because of the blur, so animations should stick to transform and opacity, and will-change should be used sparingly.

The safer approach follows web.dev. Do not animate box-shadow across long lists. Put the pressed shadow on a pseudo-element and fade its opacity instead.

Browsers and forced colors

Basic support is not the problem. box-shadow has worked across browsers since July 2015 and has been Baseline widely available since January 2018, so most cross-browser compatibility concerns here come from rendering differences, not missing features.

The real gap is forced colors mode, where browsers override box-shadow to none. Add border: 1px solid transparent to controls. The border stays invisible normally, but it appears in forced colors mode and keeps the control visible.

CSS Neumorphism FAQ

Does CSS neumorphism work on mobile screens?

Yes. Shadow offsets are fixed pixels, so they do not shrink with the element. Define offset and blur in rem or in custom properties that change per breakpoint, otherwise a 16px blur swamps a small button on a phone.

How should hover effects behave on touch devices?

Wrap hover styles in a (hover: hover) media query so touch screens skip them. The pressed inset state then becomes the main feedback, which is what phone users actually see. Keep tap targets at least 24 by 24 CSS pixels, the WCAG 2.2 minimum.

How do you create the neumorphic effect in Figma?

Fill a rectangle with the page color, then add two drop shadows. One is dark with positive X and Y offsets, the other is light with negative offsets.

Switch both to inner shadow for the pressed state. Dev Mode then lists the matching values for your CSS.

Can filter: drop-shadow replace box-shadow?

Only partly. The drop-shadow function follows the element’s alpha shape, which suits icons and irregular outlines. It has no inset or spread, so pressed states still need box-shadow. Chain two drop-shadow calls to get the raised pair.

Can a neumorphic element sit on a gradient or photo background?

Only on a very subtle gradient. The element must match whatever is behind it, and a photo or strong gradient breaks that match.

Put the controls on a solid color panel and keep the image outside it.

Does neumorphism work with Bootstrap or other component libraries?

Yes, as a custom class layered over the component. Set the component’s background to the parent color, apply your shadow tokens, and keep a border color that passes contrast.

Keep all overrides in one class, so removing the style later is a single deletion.

Where Neumorphism Belongs in a Production Interface

Toggles, knobs and secondary buttons are fine candidates. Data tables and form fields are not, and neither is the primary action.

Fix things in this order. Start with a passing border or label, then a distinct focus and state signal, and only after that the dark theme and tokens.

Contrast comes first because it blocks users, while theming only changes appearance. A shadow pair needs roughly twice the contrast of the default palette to pass alone, and that extra contrast removes the soft look.

The trade-off is a visible border on every control. The surface ends up less pure, but it stays usable.

Teams that keep the style should store the shared values in one place, and a guide on how to create a design system in Figma is the natural next step.

This position was verified on 27 October 2026 against WCAG 2.2. A revised contrast requirement for interface components would change it.