36 Tailwind CSS Modals
05 / 36

All 36 in 36 Tailwind CSS Modals

  1. 01Tailwind Login / Signup Modal with Native Dialog & Starting-Style
  2. 02Tailwind Password Reset Modal with In-Place Success State
  3. 03Tailwind Age Verification Gate with :has() and Zero JavaScript
  4. 04Tailwind Paywall Modal with Masked Article Fade
  5. 05Tailwind Product Quick-View Modal with has-checked Variant Chips
  6. 06Tailwind Shopping Cart Slide-Over Drawer with Dialog
  7. 07Tailwind Timed Newsletter Promo Popup with Frequency Cap
  8. 08Tailwind Size Guide Overlay with Zero-JS cm/inch Toggle
  9. 09Tailwind Zero-JS Lightbox Gallery with the target Variant
  10. 10Tailwind Video Trailer Modal with Aspect-Locked Iframe
  11. 11Tailwind Destructive Delete Modal with Type-to-Confirm Gate
  12. 12Tailwind Success / Error Result Modal with Drawn SVG Icon
  13. 13Tailwind GDPR Cookie Consent Banner with details Disclosure
  14. 14Tailwind Multi-Step Onboarding Modal with data-step Variants
  15. 15Tailwind Filter & Sort Drawer that Becomes a Bottom Sheet
  16. 16Tailwind Contact Support Modal with Live Validation
  17. 17Tailwind Tabbed Settings Modal with Zero-JS Radio Tabs
  18. 18Tailwind Command Palette Modal with ⌘K and Roving Selection
  19. 19Tailwind Confirm Delete Modal with Corner Slide-and-Spin Entrance
  20. 20Tailwind Login Modal with Staggered Blur-In Content Drop
  21. 21Tailwind Signup Modal with 3D Origami Fan Deploy Panels
  22. 22Tailwind Payment Checkout Modal with 3D Paper Fold Entrance
  23. 23Tailwind HIPAA Patient Consent Modal with Scroll-Gated Agreement
  24. 24Tailwind Age Verification Modal with Clip-Path Iris Aperture
  25. 25Tailwind GDPR Cookie Preferences Modal with Unfolding Category Rows
  26. 26Tailwind Video Lightbox Modal with Chromatic Glitch Materialize
  27. 27Tailwind Image Gallery Lightbox with Clip-Path Diamond Reveal
  28. 28Tailwind Add to Cart Confirmation with SVG Liquid Morph Entrance
  29. 29Tailwind Product Quick View Modal with Elastic Button Morph
  30. 30Tailwind Newsletter Signup Modal with Depth-Layer Parallax Entrance
  31. 31Tailwind Exit-Intent Discount Modal with Live Countdown and Coupon Copy
  32. 32Tailwind Restaurant Reservation Modal with Cascading Fan Panels
  33. 33Tailwind Order Success Modal with CSS-Only Confetti Burst
  34. 34Tailwind SaaS Onboarding Welcome Modal with Cascading Blur-In
  35. 35Tailwind Terms of Service Modal with Letter-Slide and Consent Gate
  36. 36Tailwind Password Reset Modal with Enumeration-Safe Confirmation
Tailwind + JSMIT licensed

Tailwind Product Quick-View Modal with has-checked Variant Chips

A two-column quick view — gallery, rating, colour and size chips, quantity stepper, Add to bag — that a shopper can evaluate without leaving the grid. Variant selection stays zero-JS: has-checked: on each label reads the radio inside it, so the chip styles itself.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="tm-05 w-full min-h-svh grid place-items-center bg-tm05-bg font-tm05-sans px-6 py-10">
  <div class="flex flex-col items-center gap-2.5">
    <button type="button" data-tm05-open class="inline-flex items-center justify-center min-h-11 px-6 rounded-xl text-[15px] font-semibold text-tm05-ink bg-tm05-btn border border-tm05-btnedge cursor-pointer transition-colors duration-200 hover:bg-tm05-btnhover hover:border-tm05-acc focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:outline-offset-2">Quick view</button>
    <span class="font-tm05-mono text-xs tracking-[0.14em] text-tm05-faint">(product-grid trigger)</span>
  </div>

  <dialog id="tm-05-dlg" aria-labelledby="tm-05-title" class="m-auto w-[min(720px,94vw)] max-h-[90dvh] overflow-auto p-0 border-0 rounded-[20px] text-tm05-ink bg-tm05-card shadow-[0_40px_90px_-30px_#000,0_0_0_1px_var(--color-tm05-edge)] opacity-0 scale-[0.97] transition-[opacity,translate,scale,overlay,display] duration-[280ms] transition-discrete open:opacity-100 open:scale-100 open:starting:opacity-0 open:starting:scale-[0.97] open:backdrop:bg-tm05-scrim open:backdrop:backdrop-blur-[5px] motion-reduce:transition-none">
    <div data-tm05-card class="@container/card relative grid grid-cols-2 @max-md/card:grid-cols-1">
      <button type="button" data-tm05-close aria-label="Close" class="absolute top-3 right-3 z-[3] grid place-items-center size-9 rounded-lg border-0 bg-tm05-xbg text-tm05-ink text-[22px] leading-none cursor-pointer transition-colors duration-200 hover:bg-tm05-well hover:text-white focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:outline-offset-2">&times;</button>

      <div class="p-4.5 bg-tm05-media">
        <div class="relative w-full h-full min-h-[280px] @max-md/card:min-h-[200px] rounded-[14px] border border-tm05-edge bg-[repeating-linear-gradient(135deg,var(--color-tm05-stripeA)_0_12px,var(--color-tm05-stripeB)_12px_24px)] after:content-['product_shot'] after:absolute after:inset-0 after:grid after:place-items-center after:font-tm05-mono after:text-xs after:tracking-[0.2em] after:uppercase after:text-tm05-faint"></div>
      </div>

      <div class="px-7 pt-7 pb-6">
        <p class="mb-2 text-[11px] tracking-[0.2em] text-tm05-acc">FIELD &amp; FORGE</p>
        <h2 id="tm-05-title" class="mb-2.5 text-2xl font-bold tracking-[-0.01em] text-tm05-hi">Merino Trail Overshirt</h2>
        <div class="mb-4 text-[12.5px] text-tm05-mut" aria-label="Rated 4.5 of 5"><span class="tracking-[1px] text-tm05-acc">★★★★</span><span class="tracking-[1px] text-tm05-acc opacity-50">★</span> <b class="text-tm05-ink">4.5</b> &middot; 218 reviews</div>
        <div class="mb-5 text-[26px] font-extrabold text-white">$148<small class="ml-1.5 text-xs font-medium text-tm05-mut">USD</small></div>

        <fieldset class="flex flex-wrap gap-2.5 mb-4 border-0 p-0">
          <legend class="basis-full mb-2.5 text-[11px] uppercase tracking-[0.16em] text-tm05-mut">Colour</legend>
          <label class="relative size-9 rounded-full cursor-pointer bg-[#3a4a3f] shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_1px_var(--color-tm05-edge)] has-checked:shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_2px_var(--color-tm05-acc)] has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-[3px]"><input type="radio" name="tm-05-c" aria-label="Forest" checked class="absolute inset-0 opacity-0 cursor-pointer"></label>
          <label class="relative size-9 rounded-full cursor-pointer bg-[#5a3f2f] shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_1px_var(--color-tm05-edge)] has-checked:shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_2px_var(--color-tm05-acc)] has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-[3px]"><input type="radio" name="tm-05-c" aria-label="Rust" class="absolute inset-0 opacity-0 cursor-pointer"></label>
          <label class="relative size-9 rounded-full cursor-pointer bg-[#2c3440] shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_1px_var(--color-tm05-edge)] has-checked:shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_2px_var(--color-tm05-acc)] has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-[3px]"><input type="radio" name="tm-05-c" aria-label="Slate" class="absolute inset-0 opacity-0 cursor-pointer"></label>
        </fieldset>

        <fieldset class="flex flex-wrap gap-2.5 mb-5 border-0 p-0">
          <legend class="basis-full mb-2.5 text-[11px] uppercase tracking-[0.16em] text-tm05-mut">Size</legend>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">S<input type="radio" name="tm-05-s" aria-label="Small" class="sr-only"></label>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">M<input type="radio" name="tm-05-s" aria-label="Medium" checked class="sr-only"></label>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">L<input type="radio" name="tm-05-s" aria-label="Large" class="sr-only"></label>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">XL<input type="radio" name="tm-05-s" aria-label="Extra large" class="sr-only"></label>
        </fieldset>

        <div class="flex gap-3 mt-2">
          <div class="flex items-center rounded-xl border border-tm05-edge overflow-hidden">
            <button type="button" data-tm05-step="-1" aria-label="Decrease quantity" class="w-11 h-12 border-0 bg-tm05-well text-tm05-ink text-xl cursor-pointer transition-colors duration-150 hover:bg-tm05-btnhover focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:-outline-offset-2">&minus;</button>
            <span id="tm-05-n" aria-live="polite" class="min-w-11 text-center text-base font-semibold text-white">1</span>
            <button type="button" data-tm05-step="1" aria-label="Increase quantity" class="w-11 h-12 border-0 bg-tm05-well text-tm05-ink text-xl cursor-pointer transition-colors duration-150 hover:bg-tm05-btnhover focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:-outline-offset-2">+</button>
          </div>
          <button type="button" data-tm05-add class="flex-1 min-h-12 rounded-xl border-0 bg-tm05-acc text-[15px] font-bold text-tm05-onacc cursor-pointer transition-[filter,translate] duration-200 hover:brightness-110 active:translate-y-px focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 motion-reduce:active:translate-y-0">Add to bag</button>
        </div>
      </div>
    </div>
  </dialog>
</section>
@import "tailwindcss";

@theme {
  --color-tm05-bg: #080a09;
  --color-tm05-bg: oklch(0.15 0.012 150);
  --color-tm05-card: #141a17;
  --color-tm05-card: oklch(0.21 0.015 150);
  --color-tm05-media: #0c110e;
  --color-tm05-media: oklch(0.17 0.012 150);
  --color-tm05-well: #0f1512;
  --color-tm05-well: oklch(0.19 0.012 150);
  --color-tm05-btn: #14201a;
  --color-tm05-btn: oklch(0.22 0.03 150);
  --color-tm05-btnedge: #2c4a3a;
  --color-tm05-btnedge: oklch(0.34 0.06 150);
  --color-tm05-btnhover: #1a2a22;
  --color-tm05-btnhover: oklch(0.26 0.04 150);
  --color-tm05-xbg: #0a0e0cb3;
  --color-tm05-xbg: oklch(0.17 0.012 150 / 0.7);
  --color-tm05-edge: #2c3a32;
  --color-tm05-edge: oklch(0.31 0.02 150);
  --color-tm05-stripeA: #18211c;
  --color-tm05-stripeA: oklch(0.23 0.018 150);
  --color-tm05-stripeB: #141b17;
  --color-tm05-stripeB: oklch(0.21 0.015 150);
  --color-tm05-hi: #f0f7f3;
  --color-tm05-hi: oklch(0.96 0.01 150);
  --color-tm05-ink: #d7e4dc;
  --color-tm05-ink: oklch(0.89 0.015 150);
  --color-tm05-mut: #9db0a5;
  --color-tm05-mut: oklch(0.74 0.02 150);
  --color-tm05-faint: #6d7f75;
  --color-tm05-faint: oklch(0.58 0.02 150);
  --color-tm05-acc: #5fd39b;
  --color-tm05-acc: oklch(0.78 0.15 155);
  --color-tm05-acctint: #1c3a2c;
  --color-tm05-acctint: oklch(0.3 0.06 155);
  --color-tm05-onacc: #04140b;
  --color-tm05-onacc: oklch(0.18 0.04 155);
  --color-tm05-scrim: #04070699;
  --color-tm05-scrim: oklch(0.12 0.008 150 / 0.6);
  --font-tm05-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-tm05-mono: 'JetBrains Mono', ui-monospace, monospace;
}

.tm-05 {
  width: 100%;
}

#tm-05-dlg::backdrop {
  background: oklch(0.12 0.008 150 / 0);
  backdrop-filter: blur(0px);
  transition: background .3s, backdrop-filter .3s, overlay .3s allow-discrete, display .3s allow-discrete;
}

@starting-style {
  #tm-05-dlg[open]::backdrop {
    background: oklch(0.12 0.008 150 / 0);
    backdrop-filter: blur(0px);
  }
}
(function () {
  var root = document.querySelector('.tm-05');
  var dlg = root.querySelector('#tm-05-dlg');
  var card = root.querySelector('[data-tm05-card]');
  var n = root.querySelector('#tm-05-n');
  root.querySelector('[data-tm05-open]').addEventListener('click', function () { dlg.showModal(); });
  root.querySelector('[data-tm05-close]').addEventListener('click', function () { dlg.close(); });
  root.querySelectorAll('[data-tm05-step]').forEach(function (b) {
    b.addEventListener('click', function () {
      n.textContent = Math.max(1, parseInt(n.textContent, 10) + parseInt(b.dataset.tm05Step, 10));
    });
  });
  var add = root.querySelector('[data-tm05-add]');
  add.addEventListener('click', function () {
    add.textContent = 'Added ✓';
    setTimeout(function () { add.textContent = 'Add to bag'; }, 1400);
  });
  dlg.addEventListener('click', function (e) {
    var r = card.getBoundingClientRect();
    if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dlg.close();
  });
})();

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-tm05-bg: #080a09;
  --color-tm05-bg: oklch(0.15 0.012 150);
  --color-tm05-card: #141a17;
  --color-tm05-card: oklch(0.21 0.015 150);
  --color-tm05-media: #0c110e;
  --color-tm05-media: oklch(0.17 0.012 150);
  --color-tm05-well: #0f1512;
  --color-tm05-well: oklch(0.19 0.012 150);
  --color-tm05-btn: #14201a;
  --color-tm05-btn: oklch(0.22 0.03 150);
  --color-tm05-btnedge: #2c4a3a;
  --color-tm05-btnedge: oklch(0.34 0.06 150);
  --color-tm05-btnhover: #1a2a22;
  --color-tm05-btnhover: oklch(0.26 0.04 150);
  --color-tm05-xbg: #0a0e0cb3;
  --color-tm05-xbg: oklch(0.17 0.012 150 / 0.7);
  --color-tm05-edge: #2c3a32;
  --color-tm05-edge: oklch(0.31 0.02 150);
  --color-tm05-stripeA: #18211c;
  --color-tm05-stripeA: oklch(0.23 0.018 150);
  --color-tm05-stripeB: #141b17;
  --color-tm05-stripeB: oklch(0.21 0.015 150);
  --color-tm05-hi: #f0f7f3;
  --color-tm05-hi: oklch(0.96 0.01 150);
  --color-tm05-ink: #d7e4dc;
  --color-tm05-ink: oklch(0.89 0.015 150);
  --color-tm05-mut: #9db0a5;
  --color-tm05-mut: oklch(0.74 0.02 150);
  --color-tm05-faint: #6d7f75;
  --color-tm05-faint: oklch(0.58 0.02 150);
  --color-tm05-acc: #5fd39b;
  --color-tm05-acc: oklch(0.78 0.15 155);
  --color-tm05-acctint: #1c3a2c;
  --color-tm05-acctint: oklch(0.3 0.06 155);
  --color-tm05-onacc: #04140b;
  --color-tm05-onacc: oklch(0.18 0.04 155);
  --color-tm05-scrim: #04070699;
  --color-tm05-scrim: oklch(0.12 0.008 150 / 0.6);
  --font-tm05-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-tm05-mono: 'JetBrains Mono', ui-monospace, monospace;
}

.tm-05 {
  width: 100%;
}

#tm-05-dlg::backdrop {
  background: oklch(0.12 0.008 150 / 0);
  backdrop-filter: blur(0px);
  transition: background .3s, backdrop-filter .3s, overlay .3s allow-discrete, display .3s allow-discrete;
}

@starting-style {
  #tm-05-dlg[open]::backdrop {
    background: oklch(0.12 0.008 150 / 0);
    backdrop-filter: blur(0px);
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • .cm-05__size:has(:checked) and .cm-05__sw:has(:checked) become has-checked: on the labels. The selected treatment now sits inside the chip's own class list, which is what makes adding a variant a copy-paste rather than a CSS edit.
  • The placeholder tile's ::after label was content:attr(data-label); as a utility it is after:content-['product_shot'] — the underscore escape becoming a space is the one piece of syntax worth memorising.
  • UI change from the CSS original: the viewport media query became a container query on the card, and every chip, stepper button and swatch grew to a 44px minimum — the original's 32px swatches and 40px sizes both failed the touch-target floor.
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 demo 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: Tailwind Product Quick-View Modal with has-checked Variant Chips
Source: https://codefronts.com/components/tailwind-css-modals/tailwind-product-quick-view-modal-has-checked-chips/

A two-column quick view — gallery, rating, colour and size chips, quantity stepper, Add to bag — that a shopper can evaluate without leaving the grid. Variant selection stays zero-JS: has-checked: on each label reads the radio inside it, so the chip styles itself.
## HTML
```html
<section class="tm-05 w-full min-h-svh grid place-items-center bg-tm05-bg font-tm05-sans px-6 py-10">
  <div class="flex flex-col items-center gap-2.5">
    <button type="button" data-tm05-open class="inline-flex items-center justify-center min-h-11 px-6 rounded-xl text-[15px] font-semibold text-tm05-ink bg-tm05-btn border border-tm05-btnedge cursor-pointer transition-colors duration-200 hover:bg-tm05-btnhover hover:border-tm05-acc focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:outline-offset-2">Quick view</button>
    <span class="font-tm05-mono text-xs tracking-[0.14em] text-tm05-faint">(product-grid trigger)</span>
  </div>

  <dialog id="tm-05-dlg" aria-labelledby="tm-05-title" class="m-auto w-[min(720px,94vw)] max-h-[90dvh] overflow-auto p-0 border-0 rounded-[20px] text-tm05-ink bg-tm05-card shadow-[0_40px_90px_-30px_#000,0_0_0_1px_var(--color-tm05-edge)] opacity-0 scale-[0.97] transition-[opacity,translate,scale,overlay,display] duration-[280ms] transition-discrete open:opacity-100 open:scale-100 open:starting:opacity-0 open:starting:scale-[0.97] open:backdrop:bg-tm05-scrim open:backdrop:backdrop-blur-[5px] motion-reduce:transition-none">
    <div data-tm05-card class="@container/card relative grid grid-cols-2 @max-md/card:grid-cols-1">
      <button type="button" data-tm05-close aria-label="Close" class="absolute top-3 right-3 z-[3] grid place-items-center size-9 rounded-lg border-0 bg-tm05-xbg text-tm05-ink text-[22px] leading-none cursor-pointer transition-colors duration-200 hover:bg-tm05-well hover:text-white focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:outline-offset-2">&times;</button>

      <div class="p-4.5 bg-tm05-media">
        <div class="relative w-full h-full min-h-[280px] @max-md/card:min-h-[200px] rounded-[14px] border border-tm05-edge bg-[repeating-linear-gradient(135deg,var(--color-tm05-stripeA)_0_12px,var(--color-tm05-stripeB)_12px_24px)] after:content-['product_shot'] after:absolute after:inset-0 after:grid after:place-items-center after:font-tm05-mono after:text-xs after:tracking-[0.2em] after:uppercase after:text-tm05-faint"></div>
      </div>

      <div class="px-7 pt-7 pb-6">
        <p class="mb-2 text-[11px] tracking-[0.2em] text-tm05-acc">FIELD &amp; FORGE</p>
        <h2 id="tm-05-title" class="mb-2.5 text-2xl font-bold tracking-[-0.01em] text-tm05-hi">Merino Trail Overshirt</h2>
        <div class="mb-4 text-[12.5px] text-tm05-mut" aria-label="Rated 4.5 of 5"><span class="tracking-[1px] text-tm05-acc">★★★★</span><span class="tracking-[1px] text-tm05-acc opacity-50">★</span> <b class="text-tm05-ink">4.5</b> &middot; 218 reviews</div>
        <div class="mb-5 text-[26px] font-extrabold text-white">$148<small class="ml-1.5 text-xs font-medium text-tm05-mut">USD</small></div>

        <fieldset class="flex flex-wrap gap-2.5 mb-4 border-0 p-0">
          <legend class="basis-full mb-2.5 text-[11px] uppercase tracking-[0.16em] text-tm05-mut">Colour</legend>
          <label class="relative size-9 rounded-full cursor-pointer bg-[#3a4a3f] shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_1px_var(--color-tm05-edge)] has-checked:shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_2px_var(--color-tm05-acc)] has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-[3px]"><input type="radio" name="tm-05-c" aria-label="Forest" checked class="absolute inset-0 opacity-0 cursor-pointer"></label>
          <label class="relative size-9 rounded-full cursor-pointer bg-[#5a3f2f] shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_1px_var(--color-tm05-edge)] has-checked:shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_2px_var(--color-tm05-acc)] has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-[3px]"><input type="radio" name="tm-05-c" aria-label="Rust" class="absolute inset-0 opacity-0 cursor-pointer"></label>
          <label class="relative size-9 rounded-full cursor-pointer bg-[#2c3440] shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_1px_var(--color-tm05-edge)] has-checked:shadow-[inset_0_0_0_2px_var(--color-tm05-card),0_0_0_2px_var(--color-tm05-acc)] has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-[3px]"><input type="radio" name="tm-05-c" aria-label="Slate" class="absolute inset-0 opacity-0 cursor-pointer"></label>
        </fieldset>

        <fieldset class="flex flex-wrap gap-2.5 mb-5 border-0 p-0">
          <legend class="basis-full mb-2.5 text-[11px] uppercase tracking-[0.16em] text-tm05-mut">Size</legend>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">S<input type="radio" name="tm-05-s" aria-label="Small" class="sr-only"></label>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">M<input type="radio" name="tm-05-s" aria-label="Medium" checked class="sr-only"></label>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">L<input type="radio" name="tm-05-s" aria-label="Large" class="sr-only"></label>
          <label class="relative grid place-items-center min-w-11 min-h-11 px-3 rounded-[9px] cursor-pointer text-sm font-semibold text-tm05-mut border border-tm05-edge bg-tm05-well transition-colors duration-150 has-checked:border-tm05-acc has-checked:bg-tm05-acctint has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-tm05-acc has-focus-visible:outline-offset-2">XL<input type="radio" name="tm-05-s" aria-label="Extra large" class="sr-only"></label>
        </fieldset>

        <div class="flex gap-3 mt-2">
          <div class="flex items-center rounded-xl border border-tm05-edge overflow-hidden">
            <button type="button" data-tm05-step="-1" aria-label="Decrease quantity" class="w-11 h-12 border-0 bg-tm05-well text-tm05-ink text-xl cursor-pointer transition-colors duration-150 hover:bg-tm05-btnhover focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:-outline-offset-2">&minus;</button>
            <span id="tm-05-n" aria-live="polite" class="min-w-11 text-center text-base font-semibold text-white">1</span>
            <button type="button" data-tm05-step="1" aria-label="Increase quantity" class="w-11 h-12 border-0 bg-tm05-well text-tm05-ink text-xl cursor-pointer transition-colors duration-150 hover:bg-tm05-btnhover focus-visible:outline-2 focus-visible:outline-tm05-acc focus-visible:-outline-offset-2">+</button>
          </div>
          <button type="button" data-tm05-add class="flex-1 min-h-12 rounded-xl border-0 bg-tm05-acc text-[15px] font-bold text-tm05-onacc cursor-pointer transition-[filter,translate] duration-200 hover:brightness-110 active:translate-y-px focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 motion-reduce:active:translate-y-0">Add to bag</button>
        </div>
      </div>
    </div>
  </dialog>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tm05-bg: #080a09;
  --color-tm05-bg: oklch(0.15 0.012 150);
  --color-tm05-card: #141a17;
  --color-tm05-card: oklch(0.21 0.015 150);
  --color-tm05-media: #0c110e;
  --color-tm05-media: oklch(0.17 0.012 150);
  --color-tm05-well: #0f1512;
  --color-tm05-well: oklch(0.19 0.012 150);
  --color-tm05-btn: #14201a;
  --color-tm05-btn: oklch(0.22 0.03 150);
  --color-tm05-btnedge: #2c4a3a;
  --color-tm05-btnedge: oklch(0.34 0.06 150);
  --color-tm05-btnhover: #1a2a22;
  --color-tm05-btnhover: oklch(0.26 0.04 150);
  --color-tm05-xbg: #0a0e0cb3;
  --color-tm05-xbg: oklch(0.17 0.012 150 / 0.7);
  --color-tm05-edge: #2c3a32;
  --color-tm05-edge: oklch(0.31 0.02 150);
  --color-tm05-stripeA: #18211c;
  --color-tm05-stripeA: oklch(0.23 0.018 150);
  --color-tm05-stripeB: #141b17;
  --color-tm05-stripeB: oklch(0.21 0.015 150);
  --color-tm05-hi: #f0f7f3;
  --color-tm05-hi: oklch(0.96 0.01 150);
  --color-tm05-ink: #d7e4dc;
  --color-tm05-ink: oklch(0.89 0.015 150);
  --color-tm05-mut: #9db0a5;
  --color-tm05-mut: oklch(0.74 0.02 150);
  --color-tm05-faint: #6d7f75;
  --color-tm05-faint: oklch(0.58 0.02 150);
  --color-tm05-acc: #5fd39b;
  --color-tm05-acc: oklch(0.78 0.15 155);
  --color-tm05-acctint: #1c3a2c;
  --color-tm05-acctint: oklch(0.3 0.06 155);
  --color-tm05-onacc: #04140b;
  --color-tm05-onacc: oklch(0.18 0.04 155);
  --color-tm05-scrim: #04070699;
  --color-tm05-scrim: oklch(0.12 0.008 150 / 0.6);
  --font-tm05-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-tm05-mono: 'JetBrains Mono', ui-monospace, monospace;
}

.tm-05 {
  width: 100%;
}

#tm-05-dlg::backdrop {
  background: oklch(0.12 0.008 150 / 0);
  backdrop-filter: blur(0px);
  transition: background .3s, backdrop-filter .3s, overlay .3s allow-discrete, display .3s allow-discrete;
}

@starting-style {
  #tm-05-dlg[open]::backdrop {
    background: oklch(0.12 0.008 150 / 0);
    backdrop-filter: blur(0px);
  }
}
```

## JavaScript
```js
(function () {
  var root = document.querySelector('.tm-05');
  var dlg = root.querySelector('#tm-05-dlg');
  var card = root.querySelector('[data-tm05-card]');
  var n = root.querySelector('#tm-05-n');
  root.querySelector('[data-tm05-open]').addEventListener('click', function () { dlg.showModal(); });
  root.querySelector('[data-tm05-close]').addEventListener('click', function () { dlg.close(); });
  root.querySelectorAll('[data-tm05-step]').forEach(function (b) {
    b.addEventListener('click', function () {
      n.textContent = Math.max(1, parseInt(n.textContent, 10) + parseInt(b.dataset.tm05Step, 10));
    });
  });
  var add = root.querySelector('[data-tm05-add]');
  add.addEventListener('click', function () {
    add.textContent = 'Added ✓';
    setTimeout(function () { add.textContent = 'Add to bag'; }, 1400);
  });
  dlg.addEventListener('click', function (e) {
    var r = card.getBoundingClientRect();
    if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dlg.close();
  });
})();
```

How this works

Every chip owns its selected state. A label wraps an invisible radio; has-checked: compiles to &:has(:checked), so the styling lives on the chip that changes. Add a fourth size by copying one label — there is no rule elsewhere to extend.

The layout query is a container query. The CSS version stacked at max-width:560px of viewport; the port asks the card instead (@container/card), which is the honest question — a 720px dialog inside a narrow embed needs to stack whatever the window is doing.

The stepper is the only JavaScript. Two buttons, an aria-live="polite" count, and a clamp at 1. Add-to-bag flips its own label for 1.4s as an optimistic confirmation.

Make it yours

  • Hydrate one dialog from the clicked grid card rather than cloning it per product.
  • Add a thumbnail strip: a second radio group whose has-checked: chips swap the main image via peer-checked: visibility.
  • Show a live line total by multiplying price by quantity inside the stepper handler.

Gotchas — read before shipping

  • Keep swatches as real radios with aria-labels. A <div> with a background colour announces nothing, and colour alone is never an accessible label.
  • Hide the radio with sr-only or a zero-size absolute box — not hidden, which removes it from the tab order and breaks arrow-key group navigation.
  • Constrain the dialog height and scroll the content, or the Add-to-bag button drops below the fold on a laptop in landscape.
  • Placeholder tiles stand in for photography here; real <img>s need width/height attributes or the grid shifts as they load.

Browser support

ChromeSafariFirefoxEdge
111+ · 117+ anim16.4+ · 17.5+ anim128+ · 129+ anim111+ · 117+ anim

:has() and size container queries are the gate, both Baseline 2023; without :has() chips lose their selected styling while the radios still work.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Arbitraryneeds bracket syntax

HTML & JSno Tailwind involved

Search CodeFronts

Loading…