47 CSS Toggles & Switches13 / 47

Pure CSSMIT licensed

Permission Stack with Live Status

A settings-panel pattern: a stack of permission rows, each with its own mini switch — and a status badge in the header that flips between 'Full access' and 'Partial' by *watching the group* with :has(). The container reads its own children's state, no script.

Published

Live Demo
Try it

The code

<section class="tgl-13" aria-label="Permission toggles with live status">
  <div class="tgl-13__card">
    <header class="tgl-13__head">
      <b>App permissions</b>
      <span class="tgl-13__badge" aria-hidden="true"><i class="tgl-13__b tgl-13__b--full">Full access</i><i class="tgl-13__b tgl-13__b--part">Partial</i><i class="tgl-13__b tgl-13__b--none">No access</i></span>
    </header>
    <label class="tgl-13__row"><span class="tgl-13__txt"><b>Camera</b><small>Photos &amp; video capture</small></span><input type="checkbox" class="tgl-13__sw" role="switch" checked></label>
    <label class="tgl-13__row"><span class="tgl-13__txt"><b>Microphone</b><small>Voice notes &amp; calls</small></span><input type="checkbox" class="tgl-13__sw" role="switch" checked></label>
    <label class="tgl-13__row"><span class="tgl-13__txt"><b>Location</b><small>While using the app</small></span><input type="checkbox" class="tgl-13__sw" role="switch"></label>
  </div>
</section>
.tgl-13,
.tgl-13 *,
.tgl-13 *::before,
.tgl-13 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tgl-13 {
  --on: oklch(0.68 0.16 155);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  background: #eef0f4;
}

.tgl-13__card {
  width: min(360px,100%);
  background: #fff;
  border-radius: 18px;
  box-shadow: 0 16px 40px -26px rgba(30,40,70,.5);
  overflow: hidden;
}

.tgl-13__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 20px;
  border-bottom: 1px solid #eef0f4;
}

.tgl-13__head > b {
  font-size: 16px;
  color: #22283a;
}

.tgl-13__badge {
  position: relative;
  font-size: 11.5px;
  font-weight: 800;
  letter-spacing: .04em;
}

.tgl-13__b {
  display: none;
  padding: 4px 10px;
  border-radius: 999px;
}

.tgl-13__b--full {
  display: inline-block;
  background: oklch(0.93 0.06 155);
  color: oklch(0.4 0.1 155);
}

.tgl-13__card:has(.tgl-13__sw:not(:checked)) .tgl-13__b--full {
  display: none;
}

.tgl-13__card:has(.tgl-13__sw:not(:checked)) .tgl-13__b--part {
  display: inline-block;
  background: oklch(0.93 0.08 80);
  color: oklch(0.45 0.11 60);
}

.tgl-13__card:not(:has(.tgl-13__sw:checked)) .tgl-13__b--part {
  display: none;
}

.tgl-13__card:not(:has(.tgl-13__sw:checked)) .tgl-13__b--none {
  display: inline-block;
  background: #eceef2;
  color: #697086;
}

.tgl-13__row {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 14px 20px;
  cursor: pointer;
  transition: background-color .2s;
}

.tgl-13__row + .tgl-13__row {
  border-top: 1px solid #f2f4f7;
}

.tgl-13__row:hover {
  background: #f8f9fc;
}

.tgl-13__txt {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.tgl-13__txt b {
  font-size: 14.5px;
  color: #7d8494;
  font-weight: 600;
  transition: color .2s;
}

.tgl-13__row:has(.tgl-13__sw:checked) .tgl-13__txt b {
  color: #22283a;
}

.tgl-13__txt small {
  font-size: 12px;
  color: #98a0b0;
}

.tgl-13__sw {
  appearance: none;
  -webkit-appearance: none;
  flex: none;
  width: 44px;
  height: 26px;
  border-radius: 14px;
  background: #e3e6ec;
  position: relative;
  cursor: pointer;
  transition: background-color .25s;
}

.tgl-13__sw::before {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 5px rgba(20,30,60,.3);
  transition: transform .25s cubic-bezier(.25,.9,.3,1.1);
}

.tgl-13__sw:checked {
  background: var(--on);
}

.tgl-13__sw:checked::before {
  transform: translateX(18px);
}

.tgl-13__sw:focus-visible {
  outline: 3px solid oklch(0.6 0.17 250);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .tgl-13__sw,
    .tgl-13__sw::before,
    .tgl-13__row,
    .tgl-13__txt b {
    transition: none;
  }
}
/* No JavaScript — the card watches its own switches with :has(): any unchecked shows 'Partial', none checked shows 'No access', all checked shows 'Full access'. */
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 Toggles & Switche 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: Permission Stack with Live Status
Source: https://codefronts.com/components/css-toggles-switches/permission-stack-with-live-status/

A settings-panel pattern: a stack of permission rows, each with its own mini switch — and a status badge in the header that flips between 'Full access' and 'Partial' by *watching the group* with :has(). The container reads its own children's state, no script.
## HTML
```html
<section class="tgl-13" aria-label="Permission toggles with live status">
  <div class="tgl-13__card">
    <header class="tgl-13__head">
      <b>App permissions</b>
      <span class="tgl-13__badge" aria-hidden="true"><i class="tgl-13__b tgl-13__b--full">Full access</i><i class="tgl-13__b tgl-13__b--part">Partial</i><i class="tgl-13__b tgl-13__b--none">No access</i></span>
    </header>
    <label class="tgl-13__row"><span class="tgl-13__txt"><b>Camera</b><small>Photos &amp; video capture</small></span><input type="checkbox" class="tgl-13__sw" role="switch" checked></label>
    <label class="tgl-13__row"><span class="tgl-13__txt"><b>Microphone</b><small>Voice notes &amp; calls</small></span><input type="checkbox" class="tgl-13__sw" role="switch" checked></label>
    <label class="tgl-13__row"><span class="tgl-13__txt"><b>Location</b><small>While using the app</small></span><input type="checkbox" class="tgl-13__sw" role="switch"></label>
  </div>
</section>
```
## CSS
```css
.tgl-13,
.tgl-13 *,
.tgl-13 *::before,
.tgl-13 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tgl-13 {
  --on: oklch(0.68 0.16 155);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  background: #eef0f4;
}

.tgl-13__card {
  width: min(360px,100%);
  background: #fff;
  border-radius: 18px;
  box-shadow: 0 16px 40px -26px rgba(30,40,70,.5);
  overflow: hidden;
}

.tgl-13__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 20px;
  border-bottom: 1px solid #eef0f4;
}

.tgl-13__head > b {
  font-size: 16px;
  color: #22283a;
}

.tgl-13__badge {
  position: relative;
  font-size: 11.5px;
  font-weight: 800;
  letter-spacing: .04em;
}

.tgl-13__b {
  display: none;
  padding: 4px 10px;
  border-radius: 999px;
}

.tgl-13__b--full {
  display: inline-block;
  background: oklch(0.93 0.06 155);
  color: oklch(0.4 0.1 155);
}

.tgl-13__card:has(.tgl-13__sw:not(:checked)) .tgl-13__b--full {
  display: none;
}

.tgl-13__card:has(.tgl-13__sw:not(:checked)) .tgl-13__b--part {
  display: inline-block;
  background: oklch(0.93 0.08 80);
  color: oklch(0.45 0.11 60);
}

.tgl-13__card:not(:has(.tgl-13__sw:checked)) .tgl-13__b--part {
  display: none;
}

.tgl-13__card:not(:has(.tgl-13__sw:checked)) .tgl-13__b--none {
  display: inline-block;
  background: #eceef2;
  color: #697086;
}

.tgl-13__row {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 14px 20px;
  cursor: pointer;
  transition: background-color .2s;
}

.tgl-13__row + .tgl-13__row {
  border-top: 1px solid #f2f4f7;
}

.tgl-13__row:hover {
  background: #f8f9fc;
}

.tgl-13__txt {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.tgl-13__txt b {
  font-size: 14.5px;
  color: #7d8494;
  font-weight: 600;
  transition: color .2s;
}

.tgl-13__row:has(.tgl-13__sw:checked) .tgl-13__txt b {
  color: #22283a;
}

.tgl-13__txt small {
  font-size: 12px;
  color: #98a0b0;
}

.tgl-13__sw {
  appearance: none;
  -webkit-appearance: none;
  flex: none;
  width: 44px;
  height: 26px;
  border-radius: 14px;
  background: #e3e6ec;
  position: relative;
  cursor: pointer;
  transition: background-color .25s;
}

.tgl-13__sw::before {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 5px rgba(20,30,60,.3);
  transition: transform .25s cubic-bezier(.25,.9,.3,1.1);
}

.tgl-13__sw:checked {
  background: var(--on);
}

.tgl-13__sw:checked::before {
  transform: translateX(18px);
}

.tgl-13__sw:focus-visible {
  outline: 3px solid oklch(0.6 0.17 250);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .tgl-13__sw,
    .tgl-13__sw::before,
    .tgl-13__row,
    .tgl-13__txt b {
    transition: none;
  }
}
```

## JavaScript
```js
/* No JavaScript — the card watches its own switches with :has(): any unchecked shows 'Partial', none checked shows 'No access', all checked shows 'Full access'. */
```

How this works

Each row is a standard mini iOS-style checkbox switch. The magic is on the card: .card:has(input:not(:checked)) matches whenever ANY switch is off, styling the badge amber 'Partial'; when every switch is on, the selector stops matching and the default green 'Full access' style shows. A second rule, :has() { } with :not(:has(:checked)), catches the all-off case for a gray 'No access'.

Rows also self-report: each label:has(:checked) darkens its own title. This is the modern replacement for the classic 'JS counts the checkboxes' widget — the state machine lives entirely in selectors, so it can never desync from the DOM.

Make it yours

  • Add rows freely — the badge logic needs zero changes, that's the point of :has().
  • Turn the badge into a count-free progress strip (background gradient stops per state).
  • Make the header badge a master 'revoke all' button (that part needs 3 lines of JS).
  • Tint each row's switch by category via per-row custom properties.

Gotchas — read before shipping

  • Order the rules default-green, then :has(unchecked) amber, then :not(:has(:checked)) gray — specificity ties resolve by source order.
  • CSS can style but not SET state: a working master toggle still needs JavaScript.
  • Keep every row a real label→input pair; the pattern's value is that AT sees a plain checkbox group.

Browser support

ChromeSafariFirefoxEdge
111+15.4+121+111+

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

:has() is stable in all evergreen browsers; without it the switches all work and only the live badge falls back to its default state.

Techniques used in this demo

Search CodeFronts

Loading…