20 CSS Kbd Keys20 / 20

Pure CSSMIT licensed

Dark Mode Kbd Keys with CSS Variables

Theme-aware key tokens plus a working light and dark switch with zero JavaScript. State lives in a visually hidden checkbox and has() on the demo root rewrites the custom properties, so one checked state flips every colour at once. The OS-default block carries the opposite-base rule too, without which the switch silently does nothing for half your visitors.

Published

Live Demo
Try it

The code

<div class="kbd-20">
  <div class="kbd-20__stage">
    <section class="kbd-20__panel">
      <header class="kbd-20__head">
        <h2 class="kbd-20__title">Theme-aware keys</h2>
        <div class="kbd-20__toggle">
          <input class="kbd-20__switch" type="checkbox" id="kbd-20-switch">
          <label class="kbd-20__track" for="kbd-20-switch">
            <span class="kbd-20__thumb" aria-hidden="true"></span>
            <span class="kbd-20__switchlabel">Dark mode</span>
          </label>
        </div>
      </header>
      <ul class="kbd-20__list">
        <li class="kbd-20__row">
          <span class="kbd-20__label">Command palette</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Ctrl</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">K</kbd></kbd>
        </li>
        <li class="kbd-20__row">
          <span class="kbd-20__label">Toggle theme</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Ctrl</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">Shift</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">L</kbd></kbd>
        </li>
        <li class="kbd-20__row">
          <span class="kbd-20__label">Save all</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Ctrl</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">Alt</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">S</kbd></kbd>
        </li>
        <li class="kbd-20__row">
          <span class="kbd-20__label">Close panel</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Esc</kbd></kbd>
        </li>
      </ul>
      <dl class="kbd-20__tokens">
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--cap</dt><dd class="kbd-20__tv">cap face</dd></div>
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--cap-ink</dt><dd class="kbd-20__tv">label colour</dd></div>
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--rule</dt><dd class="kbd-20__tv">borders and dividers</dd></div>
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--accent</dt><dd class="kbd-20__tv">switch and focus ring</dd></div>
      </dl>
    </section>
  </div>
</div>
.kbd-20 {
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  box-sizing: border-box;
  --page: #eef0f3;
  --panel: #ffffff;
  --ink: #1b2129;
  --muted: #6b7581;
  --rule: #dce1e7;
  --cap: #f3f5f8;
  --cap-border: #c9d1da;
  --cap-ink: #313b47;
  --accent: #3b6ea5;
  --font-key: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  --font-token: ui-monospace, "SFMono-Regular", Menlo, monospace;
  background: var(--page);
  color: var(--ink);
  font-family: var(--font-key);
  padding: 40px 18px;
  transition: background 220ms ease, color 220ms ease;
}

.kbd-20:has(.kbd-20__switch:checked) {
  --page: #14181d;
  --panel: #1c222a;
  --ink: #e9eef4;
  --muted: #8d99a6;
  --rule: #2b333d;
  --cap: #242c36;
  --cap-border: #3b4653;
  --cap-ink: #dce6f1;
  --accent: #7fb0e8;
}

.kbd-20__stage {
  display: grid;
  place-items: center;
  min-height: calc(100vh - 80px);
}

.kbd-20__panel {
  inline-size: 100%;
  max-inline-size: 30rem;
  min-inline-size: 0;
  background: var(--panel);
  border: 1px solid var(--rule);
  border-radius: 12px;
  padding: 22px 22px 18px;
  transition: background 220ms ease, border-color 220ms ease;
}

.kbd-20__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--rule);
  margin-bottom: 6px;
}

.kbd-20__title {
  margin: 0;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: -0.01em;
  min-inline-size: 0;
}

.kbd-20__toggle {
  position: relative;
  min-inline-size: 0;
}

.kbd-20__switch {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  opacity: 0;
  pointer-events: none;
}

.kbd-20__track {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  min-block-size: 44px;
  padding: 0 4px;
  cursor: pointer;
  user-select: none;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.kbd-20__thumb {
  position: relative;
  inline-size: 42px;
  block-size: 24px;
  flex: none;
  border-radius: 999px;
  background: var(--rule);
  border: 1px solid var(--cap-border);
}
/* The off-state fill and the edge are derived from the ink token, not from the
   divider tokens: a control boundary is measured against the panel it sits on,
   and the knob has to stay distinguishable from the fill behind it. */

@supports (background: color-mix(in oklab, currentColor 50%, transparent)) {
  .kbd-20__thumb {
    background: color-mix(in oklab, var(--ink) 52%, transparent);
    border-color: color-mix(in oklab, var(--ink) 60%, transparent);
  }
}

.kbd-20__thumb::after {
  content: "";
  position: absolute;
  inset-block-start: 2px;
  inset-inline-start: 2px;
  inline-size: 18px;
  block-size: 18px;
  border-radius: 50%;
  background: var(--panel);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  transition: transform 200ms cubic-bezier(0.3, 0.8, 0.3, 1);
}

.kbd-20:has(.kbd-20__switch:checked) .kbd-20__thumb {
  background: var(--accent);
}

.kbd-20:has(.kbd-20__switch:checked) .kbd-20__thumb::after {
  transform: translateX(18px);
}

.kbd-20__switch:focus-visible + .kbd-20__track {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
  border-radius: 8px;
}

.kbd-20__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 1px;
}

.kbd-20__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 11px 0;
  border-bottom: 1px solid var(--rule);
}

.kbd-20__row:last-child {
  border-bottom: 0;
}

.kbd-20__label {
  font-size: 13.5px;
  min-inline-size: 0;
  flex: 1 1 9rem;
}

.kbd-20__combo {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex-wrap: nowrap;
  font-family: inherit;
  min-inline-size: 0;
}

.kbd-20__key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: 28px;
  min-block-size: 26px;
  padding: 0 8px;
  box-sizing: border-box;
  font-family: inherit;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  color: var(--cap-ink);
  background: var(--cap);
  border: 1px solid var(--cap-border);
  border-radius: 6px;
  white-space: nowrap;
  transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
}

.kbd-20__sep {
  font-size: 11px;
  color: var(--muted);
  user-select: none;
}

.kbd-20__tokens {
  margin: 16px 0 0;
  padding-top: 14px;
  border-top: 1px solid var(--rule);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
  gap: 8px 20px;
}

.kbd-20__trow {
  display: flex;
  align-items: baseline;
  gap: 10px;
  min-inline-size: 0;
}

.kbd-20__tk {
  margin: 0;
  font-family: var(--font-token);
  font-size: 11px;
  color: var(--cap-ink);
  background: var(--cap);
  padding: 2px 5px;
  border-radius: 3px;
  flex: none;
}

.kbd-20__tv {
  margin: 0;
  font-size: 11.5px;
  color: var(--muted);
  min-inline-size: 0;
}

@media (prefers-reduced-motion: reduce) {
  .kbd-20,
    .kbd-20__panel,
    .kbd-20__key,
    .kbd-20__thumb,
    .kbd-20__thumb::after {
    transition: none;
  }
}

@media (prefers-color-scheme: dark) {
  .kbd-20:not(:has(.kbd-20__switch:checked)) {
    --page: #14181d;
    --panel: #1c222a;
    --ink: #e9eef4;
    --muted: #8d99a6;
    --rule: #2b333d;
    --cap: #242c36;
    --cap-border: #3b4653;
    --cap-ink: #dce6f1;
    --accent: #7fb0e8;
  }

  .kbd-20:has(.kbd-20__switch:checked) {
    --page: #eef0f3;
    --panel: #ffffff;
    --ink: #1b2129;
    --muted: #6b7581;
    --rule: #dce1e7;
    --cap: #f3f5f8;
    --cap-border: #c9d1da;
    --cap-ink: #313b47;
    --accent: #3b6ea5;
  }
}

.kbd-20[data-theme="dark"],
[data-theme="dark"] .kbd-20 {
  --page: #14181d;
  --panel: #1c222a;
  --ink: #e9eef4;
  --muted: #8d99a6;
  --rule: #2b333d;
  --cap: #242c36;
  --cap-border: #3b4653;
  --cap-ink: #dce6f1;
  --accent: #7fb0e8;
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Kbd Key from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Dark Mode Kbd Keys with CSS Variables
Source: https://codefronts.com/snippets/css-kbd-keys/dark-mode-kbd-keys-with-css-variables/

Theme-aware key tokens plus a working light and dark switch with zero JavaScript. State lives in a visually hidden checkbox and has() on the demo root rewrites the custom properties, so one checked state flips every colour at once. The OS-default block carries the opposite-base rule too, without which the switch silently does nothing for half your visitors.
## HTML
```html
<div class="kbd-20">
  <div class="kbd-20__stage">
    <section class="kbd-20__panel">
      <header class="kbd-20__head">
        <h2 class="kbd-20__title">Theme-aware keys</h2>
        <div class="kbd-20__toggle">
          <input class="kbd-20__switch" type="checkbox" id="kbd-20-switch">
          <label class="kbd-20__track" for="kbd-20-switch">
            <span class="kbd-20__thumb" aria-hidden="true"></span>
            <span class="kbd-20__switchlabel">Dark mode</span>
          </label>
        </div>
      </header>
      <ul class="kbd-20__list">
        <li class="kbd-20__row">
          <span class="kbd-20__label">Command palette</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Ctrl</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">K</kbd></kbd>
        </li>
        <li class="kbd-20__row">
          <span class="kbd-20__label">Toggle theme</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Ctrl</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">Shift</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">L</kbd></kbd>
        </li>
        <li class="kbd-20__row">
          <span class="kbd-20__label">Save all</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Ctrl</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">Alt</kbd><span class="kbd-20__sep" aria-hidden="true">+</span><kbd class="kbd-20__key">S</kbd></kbd>
        </li>
        <li class="kbd-20__row">
          <span class="kbd-20__label">Close panel</span>
          <kbd class="kbd-20__combo"><kbd class="kbd-20__key">Esc</kbd></kbd>
        </li>
      </ul>
      <dl class="kbd-20__tokens">
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--cap</dt><dd class="kbd-20__tv">cap face</dd></div>
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--cap-ink</dt><dd class="kbd-20__tv">label colour</dd></div>
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--rule</dt><dd class="kbd-20__tv">borders and dividers</dd></div>
        <div class="kbd-20__trow"><dt class="kbd-20__tk">--accent</dt><dd class="kbd-20__tv">switch and focus ring</dd></div>
      </dl>
    </section>
  </div>
</div>
```
## CSS
```css
.kbd-20 {
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  box-sizing: border-box;
  --page: #eef0f3;
  --panel: #ffffff;
  --ink: #1b2129;
  --muted: #6b7581;
  --rule: #dce1e7;
  --cap: #f3f5f8;
  --cap-border: #c9d1da;
  --cap-ink: #313b47;
  --accent: #3b6ea5;
  --font-key: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  --font-token: ui-monospace, "SFMono-Regular", Menlo, monospace;
  background: var(--page);
  color: var(--ink);
  font-family: var(--font-key);
  padding: 40px 18px;
  transition: background 220ms ease, color 220ms ease;
}

.kbd-20:has(.kbd-20__switch:checked) {
  --page: #14181d;
  --panel: #1c222a;
  --ink: #e9eef4;
  --muted: #8d99a6;
  --rule: #2b333d;
  --cap: #242c36;
  --cap-border: #3b4653;
  --cap-ink: #dce6f1;
  --accent: #7fb0e8;
}

.kbd-20__stage {
  display: grid;
  place-items: center;
  min-height: calc(100vh - 80px);
}

.kbd-20__panel {
  inline-size: 100%;
  max-inline-size: 30rem;
  min-inline-size: 0;
  background: var(--panel);
  border: 1px solid var(--rule);
  border-radius: 12px;
  padding: 22px 22px 18px;
  transition: background 220ms ease, border-color 220ms ease;
}

.kbd-20__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--rule);
  margin-bottom: 6px;
}

.kbd-20__title {
  margin: 0;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: -0.01em;
  min-inline-size: 0;
}

.kbd-20__toggle {
  position: relative;
  min-inline-size: 0;
}

.kbd-20__switch {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  opacity: 0;
  pointer-events: none;
}

.kbd-20__track {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  min-block-size: 44px;
  padding: 0 4px;
  cursor: pointer;
  user-select: none;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.kbd-20__thumb {
  position: relative;
  inline-size: 42px;
  block-size: 24px;
  flex: none;
  border-radius: 999px;
  background: var(--rule);
  border: 1px solid var(--cap-border);
}
/* The off-state fill and the edge are derived from the ink token, not from the
   divider tokens: a control boundary is measured against the panel it sits on,
   and the knob has to stay distinguishable from the fill behind it. */

@supports (background: color-mix(in oklab, currentColor 50%, transparent)) {
  .kbd-20__thumb {
    background: color-mix(in oklab, var(--ink) 52%, transparent);
    border-color: color-mix(in oklab, var(--ink) 60%, transparent);
  }
}

.kbd-20__thumb::after {
  content: "";
  position: absolute;
  inset-block-start: 2px;
  inset-inline-start: 2px;
  inline-size: 18px;
  block-size: 18px;
  border-radius: 50%;
  background: var(--panel);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  transition: transform 200ms cubic-bezier(0.3, 0.8, 0.3, 1);
}

.kbd-20:has(.kbd-20__switch:checked) .kbd-20__thumb {
  background: var(--accent);
}

.kbd-20:has(.kbd-20__switch:checked) .kbd-20__thumb::after {
  transform: translateX(18px);
}

.kbd-20__switch:focus-visible + .kbd-20__track {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
  border-radius: 8px;
}

.kbd-20__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 1px;
}

.kbd-20__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 11px 0;
  border-bottom: 1px solid var(--rule);
}

.kbd-20__row:last-child {
  border-bottom: 0;
}

.kbd-20__label {
  font-size: 13.5px;
  min-inline-size: 0;
  flex: 1 1 9rem;
}

.kbd-20__combo {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex-wrap: nowrap;
  font-family: inherit;
  min-inline-size: 0;
}

.kbd-20__key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: 28px;
  min-block-size: 26px;
  padding: 0 8px;
  box-sizing: border-box;
  font-family: inherit;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  color: var(--cap-ink);
  background: var(--cap);
  border: 1px solid var(--cap-border);
  border-radius: 6px;
  white-space: nowrap;
  transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
}

.kbd-20__sep {
  font-size: 11px;
  color: var(--muted);
  user-select: none;
}

.kbd-20__tokens {
  margin: 16px 0 0;
  padding-top: 14px;
  border-top: 1px solid var(--rule);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
  gap: 8px 20px;
}

.kbd-20__trow {
  display: flex;
  align-items: baseline;
  gap: 10px;
  min-inline-size: 0;
}

.kbd-20__tk {
  margin: 0;
  font-family: var(--font-token);
  font-size: 11px;
  color: var(--cap-ink);
  background: var(--cap);
  padding: 2px 5px;
  border-radius: 3px;
  flex: none;
}

.kbd-20__tv {
  margin: 0;
  font-size: 11.5px;
  color: var(--muted);
  min-inline-size: 0;
}

@media (prefers-reduced-motion: reduce) {
  .kbd-20,
    .kbd-20__panel,
    .kbd-20__key,
    .kbd-20__thumb,
    .kbd-20__thumb::after {
    transition: none;
  }
}

@media (prefers-color-scheme: dark) {
  .kbd-20:not(:has(.kbd-20__switch:checked)) {
    --page: #14181d;
    --panel: #1c222a;
    --ink: #e9eef4;
    --muted: #8d99a6;
    --rule: #2b333d;
    --cap: #242c36;
    --cap-border: #3b4653;
    --cap-ink: #dce6f1;
    --accent: #7fb0e8;
  }

  .kbd-20:has(.kbd-20__switch:checked) {
    --page: #eef0f3;
    --panel: #ffffff;
    --ink: #1b2129;
    --muted: #6b7581;
    --rule: #dce1e7;
    --cap: #f3f5f8;
    --cap-border: #c9d1da;
    --cap-ink: #313b47;
    --accent: #3b6ea5;
  }
}

.kbd-20[data-theme="dark"],
[data-theme="dark"] .kbd-20 {
  --page: #14181d;
  --panel: #1c222a;
  --ink: #e9eef4;
  --muted: #8d99a6;
  --rule: #2b333d;
  --cap: #242c36;
  --cap-border: #3b4653;
  --cap-ink: #dce6f1;
  --accent: #7fb0e8;
}
```

How this works

One checked state, one token block, whole palette. Every colour in the demo reads from a custom property on the root, so .kbd-20:has(.kbd-20__switch:checked) redeclaring six properties reskins the entire component. Nothing is toggled per element, no class is added, and there is no script — the selector engine holds the state.

The flip-back rule is the part people miss. Suppose the demo defaults to light and also honours prefers-color-scheme: dark. A visitor on a dark OS now sees dark, but the checkbox is unchecked; checking it applies the light values, which the media query then overrides. The switch appears broken. The fix: inside each OS-default block, declare BOTH the default base for the unchecked state and the full opposite base for the checked state. That is why the dark media query here contains a :not(:has(...)) rule and a matching :has(...) rule with every value spelled out.

The switch has to be a real control. The checkbox is clipped rather than removed, so it keeps its role and stays in the tab order; the visible track is its <label> at 44px minimum with an accessible name, and a :focus-visible ring is forwarded from the input to the label. Keyboard users can reach it, see it, and toggle it with Space.

Token names should describe roles, not colours. --cap, --cap-ink, --rule stay meaningful in both themes; --light-grey would not. That is what lets the theme block be six lines instead of a duplicate stylesheet — and it is the same token vocabulary every other demo in this collection uses.

Make it yours

  • Add a third theme by swapping the checkbox for a radio group and adding one more token block.
  • Retint either theme by editing only the token declarations.
  • Change the switch to a 52px track for a larger target.
  • Move the switch to the panel footer — :has() does not care where it sits.
  • Add a --radius token so both themes can carry different corner treatments.
  • Extend the token set with --cap-edge if you want raised caps in light and flat in dark.

Gotchas — read before shipping

  • Without the flip-back rule inside the OS-default media block, the switch does nothing for visitors whose OS preference already matches the demo default.
  • Hiding the checkbox with display:none takes it out of the tab order and the switch stops working for keyboard users.
  • Any hardcoded colour left in a rule survives the theme flip and becomes the one wrong element in the panel.
  • Naming tokens after colours instead of roles forces a full rule rewrite per theme rather than a token swap.

Browser support

ChromeSafariFirefoxEdge
111+16.2+121+111+

Floor set by :has(), color-mix() as this demo is written. The core technique itself goes back further — Chrome 105+.

:has() gates the manual switch. Without it the demo still themes correctly from prefers-color-scheme — only the visible toggle stops responding, and no content is hidden.

Techniques used in this demo

Search CodeFronts

Loading…