Skip to content

Elevation & depth

In review

Depth in Nockerl is lit from above: a neutral (or tinted-black) drop shadow plus a subtle top catch-light. No glows, no colored shadows, no emission. This is the most load-bearing of the design laws.

  • Cards lift, fields sink. A card rises off the ground: lighter surface + drop shadow + a 1px top catch-light. An input is the inverse, a recessed well: darker + inner shadow.
  • Ground is the darkest layer. The canvas is darkest; cards are a clear luminance step above. Lift comes from the gap + shadow + catch-light, never from making a card light-grey.
  • One light source. Everything is lit from the top, so highlights sit on top edges and shadows fall below. A consistent source is what makes the depth read as real.
  • The fill is static; the shadow grows. Moving a card up a tier deepens the shadow. It never lightens the surface. --color-card-surface1 is the resting fill at every rung.

Two neutral tokens carry all depth, the same pair on both cards and wells:

Token Value (dark) Value (light) Role
--color-shadow-tint #000000 #1A2733 The drop / inner shadow color (mixed with transparent per rung)
--color-surface-highlight #FFFFFF0F (~6% white) #FFFFFF14 (~8% white) The 1px top catch-light

Neither is an accent hue: depth stays monochrome by construction.

Cards lift off the ground on a four-rung neutral ladder. The --elevation-level* tokens are the blur radius; the vertical offset and the shadow-tint alpha both climb with the tier, so a higher card casts a softer, deeper, slightly stronger shadow. The per-rung opacity is its own token ladder, --elevation-shadow-tint-alpha-* (shadowTintAlpha, a unitless 0 to 1 number, × 100% in the web color-mix).

Blur token Blur radius Offset · alpha token Use
elevation-level1 2px 0 2px … -3px · shadow-tint-alpha-level1 @28% Resting cards, chips, inline rows
elevation-level2 5px 0 4px … -6px · shadow-tint-alpha-level2 @30% Standard cards, message bubbles, menus
elevation-level3 9px 0 8px … -8px · shadow-tint-alpha-level3 @33% Input bar, status pills, popovers, dialogs
elevation-sheet 14px 0 16px … -10px · shadow-tint-alpha-sheet @35% Bottom sheets, modals

This is the exact recipe the Surface / Card primitive encodes (every value is a token, so a depth change is one edit). The offset and blur come from --space-* and --elevation-*; the negative spread keeps the shadow tight under the card:

.nk-surface {
background: var(--color-card-surface1);
border-radius: var(--radius-card); /* 16px card / 12px panel */
border: var(--space-px) solid var(--color-card-hairline);
/* the top catch-light, reused by hover/press rules */
--nk-surface-sheen: inset 0 var(--space-px) 0 var(--color-surface-highlight);
}
/* resting rung (L1) */
.nk-surface--l1 { box-shadow:
0 var(--space-0-5) var(--elevation-level1) -3px
color-mix(in srgb, var(--color-shadow-tint) calc(var(--elevation-shadow-tint-alpha-level1) * 100%), transparent), /* neutral drop */
var(--nk-surface-sheen); } /* top catch-light */
/* standard card (L2) */
.nk-surface--l2 { box-shadow:
0 var(--space-1) var(--elevation-level2) -6px
color-mix(in srgb, var(--color-shadow-tint) calc(var(--elevation-shadow-tint-alpha-level2) * 100%), transparent),
var(--nk-surface-sheen); }
/* raised (L3) */
.nk-surface--l3 { box-shadow:
0 var(--space-2) var(--elevation-level3) -8px
color-mix(in srgb, var(--color-shadow-tint) calc(var(--elevation-shadow-tint-alpha-level3) * 100%), transparent),
var(--nk-surface-sheen); }
/* sheet (L4) */
.nk-surface--l4 { box-shadow:
0 var(--space-4) var(--elevation-sheet) -10px
color-mix(in srgb, var(--color-shadow-tint) calc(var(--elevation-shadow-tint-alpha-sheet) * 100%), transparent),
var(--nk-surface-sheen); }

Resolving the tokens, the four rungs are (alpha from the shadowTintAlpha ladder):

/* L1 */ box-shadow: 0 2px 2px -3px color-mix(in srgb, var(--color-shadow-tint) 28%, transparent), inset 0 1px 0 var(--color-surface-highlight);
/* L2 */ box-shadow: 0 4px 5px -6px color-mix(in srgb, var(--color-shadow-tint) 30%, transparent), inset 0 1px 0 var(--color-surface-highlight);
/* L3 */ box-shadow: 0 8px 9px -8px color-mix(in srgb, var(--color-shadow-tint) 33%, transparent), inset 0 1px 0 var(--color-surface-highlight);
/* L4 */ box-shadow: 0 16px 14px -10px color-mix(in srgb, var(--color-shadow-tint) 35%, transparent), inset 0 1px 0 var(--color-surface-highlight);

Fields are the inverse of a card: instead of lifting off the ground they sink into it. The Well primitive (the shared home for TextField / TextArea) fills with the darker --color-canvas-alt, then presses in with an inner shadow plus the same 1px top catch-light. Nothing here lifts and nothing glows:

.nk-well {
background: var(--color-canvas-alt); /* darker than the card ground */
border: var(--space-px) solid var(--color-outline-subtle);
border-radius: var(--radius-control); /* 12px, a rounded rect, never a pill */
box-shadow:
inset 0 var(--space-0-5) var(--space-1)
color-mix(in srgb, var(--color-shadow-tint) 45%, transparent), /* inner sink shadow */
inset 0 var(--space-px) 0 var(--color-surface-highlight); /* 1px top catch-light */
}

Resolved, the resting well is:

box-shadow:
inset 0 2px 4px color-mix(in srgb, var(--color-shadow-tint) 45%, transparent),
inset 0 1px 0 var(--color-surface-highlight);

Focus keeps the well recessed and adds a cyan outline ring, a second box-shadow layer, not a glow, and not colored depth. The inner sink shadow stays; the ring hugs the control radius:

.nk-well.is-focus {
border-color: var(--color-accent-primary);
box-shadow:
inset 0 var(--space-0-5) var(--space-1)
color-mix(in srgb, var(--color-shadow-tint) 45%, transparent), /* still sunk */
0 0 0 var(--space-0-5)
color-mix(in srgb, var(--color-accent-primary) 45%, transparent); /* cyan outline ring */
}

Error / warning / success swap the border color (never the only signal, always paired with an icon + helper text); disabled and read-only flatten the inner shadow but stay visible at ≥ 3:1. The sink shadow is present in every state: a field never lifts.

Web resolves the ladder to the box-shadow recipes above. The --elevation-* tokens are stored in px and the Compose build emits them as dp drop-shadow levels, paired with the same shadowTint + surfaceHighlight to reproduce the lit-from-above material; SwiftUI follows the same levels. The token layer is shared; only the shadow syntax is platform-native. The exact Compose / SwiftUI shadow-emission mapping is documented alongside each platform’s Surface implementation as those slices are conformed.