14 / 18
All 18 in 18 Tailwind CSS Product Cards
- 01Tailwind Product Card with Hover-Reveal Add to Cart
- 02Pure-Tailwind 3D Flip Product Card with Spec Back
- 03Tailwind Contained Image Zoom Product Card
- 04Tailwind Mouse-Tracking 3D Tilt Product Card
- 05Tailwind Slide-Up Details Panel Product Card
- 06Tailwind Minimalist eCommerce Product Card
- 07Tailwind Glassmorphism Product Card
- 08Tailwind Dark-Mode Gadget Product Card with Accent Glow
- 09Tailwind Neon Glowing Conic-Border Product Card
- 10Tailwind Material-Design Elevation Product Card
- 11Tailwind Horizontal Product Card with Container Queries
- 12Tailwind Auto-Fit Product Catalog Grid
- 13Tailwind Sale Ribbon & Coupon Badge Product Card
- 14Tailwind Sneaker Card with Colourway & Size Picker
- 15Tailwind Digital Download Product Card
- 16Tailwind Food Delivery Card with Quantity Stepper
- 17Tailwind Real Estate Listing Card with Favourite Toggle
- 18Tailwind Subscription Pricing Card with Billing Switch
Tailwind Sneaker Card with Colourway & Size Picker
Colourway swatches that retint the whole card and a row of size chips — no JavaScript, and in v4 no bespoke selectors either. peer-checked: styles the swatch from its own hidden radio, and has-[.tpc14-sw1_input:checked]:[--tint:…] on the root turns the picked colour into a live custom property the media and CTA both read.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tpc-14 w-full min-h-svh grid place-items-center bg-tpc14-bg font-tpc14-sans px-5 py-12 [--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw1_input:checked]:[--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw2_input:checked]:[--tint:oklch(0.6_0.14_250)] has-[.tpc14-sw3_input:checked]:[--tint:oklch(0.62_0.15_150)] has-[.tpc14-sw4_input:checked]:[--tint:oklch(0.42_0.03_260)]" aria-label="Sneaker colourway and size picker demo">
<article class="w-[min(340px,100%)] rounded-[20px] overflow-hidden bg-tpc14-card shadow-[0_18px_44px_-24px_oklch(0.25_0.03_260/0.45)]">
<div role="img" aria-label="Product photo: Aurora Runner sneaker" class="aspect-[16/11] bg-[image:linear-gradient(135deg,color-mix(in_oklch,var(--tint)_42%,transparent),color-mix(in_oklch,var(--tint)_16%,transparent)),url('https://images.unsplash.com/photo-1600185365926-3a2ce3cdb9eb?w=800&h=550&fit=crop&q=70')] bg-cover bg-center transition-[background] duration-400 motion-reduce:transition-none"></div>
<div class="px-5 pt-4.5 pb-5">
<p class="font-tpc14-sans text-[11px] font-medium tracking-[0.2em] uppercase text-tpc14-mut">Aerolite</p>
<h3 class="mt-1 text-[20px] font-bold text-tpc14-ink">Aurora Runner</h3>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Colour</legend>
<div class="flex gap-2.5">
<label class="tpc14-sw1 cursor-pointer [--sw:oklch(0.6_0.16_25)]">
<input type="radio" name="tpc14-color" class="peer sr-only" checked>
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Ember red</span>
</label>
<label class="tpc14-sw2 cursor-pointer [--sw:oklch(0.6_0.14_250)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Cobalt blue</span>
</label>
<label class="tpc14-sw3 cursor-pointer [--sw:oklch(0.62_0.15_150)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Pine green</span>
</label>
<label class="tpc14-sw4 cursor-pointer [--sw:oklch(0.42_0.03_260)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Graphite</span>
</label>
</div>
</fieldset>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Size (UK)</legend>
<div class="flex flex-wrap gap-2">
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">7</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only" checked><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">8</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">9</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">10</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">11</span></label>
</div>
</fieldset>
<div class="mt-4.5 flex items-center justify-between gap-3">
<p class="text-[22px] font-bold text-tpc14-ink tabular-nums">$140</p>
<button type="button" class="min-h-11 h-11.5 px-5.5 rounded-xl border-0 cursor-pointer bg-[var(--tint)] text-tpc14-onacc text-[14px] font-bold transition-[background-color,filter] duration-300 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc14-ink focus-visible:outline-offset-2 motion-reduce:transition-none">Add to Cart</button>
</div>
</div>
</article>
</section><section class="tpc-14 w-full min-h-svh grid place-items-center bg-tpc14-bg font-tpc14-sans px-5 py-12 [--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw1_input:checked]:[--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw2_input:checked]:[--tint:oklch(0.6_0.14_250)] has-[.tpc14-sw3_input:checked]:[--tint:oklch(0.62_0.15_150)] has-[.tpc14-sw4_input:checked]:[--tint:oklch(0.42_0.03_260)]" aria-label="Sneaker colourway and size picker demo">
<article class="w-[min(340px,100%)] rounded-[20px] overflow-hidden bg-tpc14-card shadow-[0_18px_44px_-24px_oklch(0.25_0.03_260/0.45)]">
<div role="img" aria-label="Product photo: Aurora Runner sneaker" class="aspect-[16/11] bg-[image:linear-gradient(135deg,color-mix(in_oklch,var(--tint)_42%,transparent),color-mix(in_oklch,var(--tint)_16%,transparent)),url('https://images.unsplash.com/photo-1600185365926-3a2ce3cdb9eb?w=800&h=550&fit=crop&q=70')] bg-cover bg-center transition-[background] duration-400 motion-reduce:transition-none"></div>
<div class="px-5 pt-4.5 pb-5">
<p class="font-tpc14-sans text-[11px] font-medium tracking-[0.2em] uppercase text-tpc14-mut">Aerolite</p>
<h3 class="mt-1 text-[20px] font-bold text-tpc14-ink">Aurora Runner</h3>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Colour</legend>
<div class="flex gap-2.5">
<label class="tpc14-sw1 cursor-pointer [--sw:oklch(0.6_0.16_25)]">
<input type="radio" name="tpc14-color" class="peer sr-only" checked>
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Ember red</span>
</label>
<label class="tpc14-sw2 cursor-pointer [--sw:oklch(0.6_0.14_250)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Cobalt blue</span>
</label>
<label class="tpc14-sw3 cursor-pointer [--sw:oklch(0.62_0.15_150)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Pine green</span>
</label>
<label class="tpc14-sw4 cursor-pointer [--sw:oklch(0.42_0.03_260)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Graphite</span>
</label>
</div>
</fieldset>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Size (UK)</legend>
<div class="flex flex-wrap gap-2">
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">7</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only" checked><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">8</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">9</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">10</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">11</span></label>
</div>
</fieldset>
<div class="mt-4.5 flex items-center justify-between gap-3">
<p class="text-[22px] font-bold text-tpc14-ink tabular-nums">$140</p>
<button type="button" class="min-h-11 h-11.5 px-5.5 rounded-xl border-0 cursor-pointer bg-[var(--tint)] text-tpc14-onacc text-[14px] font-bold transition-[background-color,filter] duration-300 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc14-ink focus-visible:outline-offset-2 motion-reduce:transition-none">Add to Cart</button>
</div>
</div>
</article>
</section>@import "tailwindcss";
@theme {
--color-tpc14-bg: #f0f1f4;
--color-tpc14-bg: oklch(0.95 0.004 265);
--color-tpc14-card: #ffffff;
--color-tpc14-card: oklch(1 0 0);
--color-tpc14-ink: #1b2233;
--color-tpc14-ink: oklch(0.26 0.025 265);
--color-tpc14-mut: #5c6577;
--color-tpc14-mut: oklch(0.5 0.022 265);
--color-tpc14-chipink: #344054;
--color-tpc14-chipink: oklch(0.4 0.03 265);
--color-tpc14-edge: #d0d5dd;
--color-tpc14-edge: oklch(0.86 0.012 265);
--color-tpc14-onacc: #ffffff;
--color-tpc14-onacc: oklch(1 0 0);
--font-tpc14-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* --tint is NOT declared here on purpose. An unlayered rule outranks anything in
@layer utilities, so a fallback written here would permanently beat the
has-[…]:[--tint:…] utilities. The default ships as a utility instead. */
.tpc-14 {
width: 100%;
min-height: 100svh;
}
.tpc-14 ::selection {
background: var(--tint);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tpc14-bg: #f0f1f4;
--color-tpc14-bg: oklch(0.95 0.004 265);
--color-tpc14-card: #ffffff;
--color-tpc14-card: oklch(1 0 0);
--color-tpc14-ink: #1b2233;
--color-tpc14-ink: oklch(0.26 0.025 265);
--color-tpc14-mut: #5c6577;
--color-tpc14-mut: oklch(0.5 0.022 265);
--color-tpc14-chipink: #344054;
--color-tpc14-chipink: oklch(0.4 0.03 265);
--color-tpc14-edge: #d0d5dd;
--color-tpc14-edge: oklch(0.86 0.012 265);
--color-tpc14-onacc: #ffffff;
--color-tpc14-onacc: oklch(1 0 0);
--font-tpc14-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* --tint is NOT declared here on purpose. An unlayered rule outranks anything in
@layer utilities, so a fallback written here would permanently beat the
has-[…]:[--tint:…] utilities. The default ships as a utility instead. */
.tpc-14 {
width: 100%;
min-height: 100svh;
}
.tpc-14 ::selection {
background: var(--tint);
color: #fff;
}Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<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.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tpc14-bg: #f0f1f4;
--color-tpc14-bg: oklch(0.95 0.004 265);
--color-tpc14-card: #ffffff;
--color-tpc14-card: oklch(1 0 0);
--color-tpc14-ink: #1b2233;
--color-tpc14-ink: oklch(0.26 0.025 265);
--color-tpc14-mut: #5c6577;
--color-tpc14-mut: oklch(0.5 0.022 265);
--color-tpc14-chipink: #344054;
--color-tpc14-chipink: oklch(0.4 0.03 265);
--color-tpc14-edge: #d0d5dd;
--color-tpc14-edge: oklch(0.86 0.012 265);
--color-tpc14-onacc: #ffffff;
--color-tpc14-onacc: oklch(1 0 0);
--font-tpc14-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* --tint is NOT declared here on purpose. An unlayered rule outranks anything in
@layer utilities, so a fallback written here would permanently beat the
has-[…]:[--tint:…] utilities. The default ships as a utility instead. */
.tpc-14 {
width: 100%;
min-height: 100svh;
}
.tpc-14 ::selection {
background: var(--tint);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tpc14-bg: #f0f1f4;
--color-tpc14-bg: oklch(0.95 0.004 265);
--color-tpc14-card: #ffffff;
--color-tpc14-card: oklch(1 0 0);
--color-tpc14-ink: #1b2233;
--color-tpc14-ink: oklch(0.26 0.025 265);
--color-tpc14-mut: #5c6577;
--color-tpc14-mut: oklch(0.5 0.022 265);
--color-tpc14-chipink: #344054;
--color-tpc14-chipink: oklch(0.4 0.03 265);
--color-tpc14-edge: #d0d5dd;
--color-tpc14-edge: oklch(0.86 0.012 265);
--color-tpc14-onacc: #ffffff;
--color-tpc14-onacc: oklch(1 0 0);
--font-tpc14-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* --tint is NOT declared here on purpose. An unlayered rule outranks anything in
@layer utilities, so a fallback written here would permanently beat the
has-[…]:[--tint:…] utilities. The default ships as a utility instead. */
.tpc-14 {
width: 100%;
min-height: 100svh;
}
.tpc-14 ::selection {
background: var(--tint);
color: #fff;
}<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&display=swap">
<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&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The four
:has()rules from the CSS original move onto the root ashas-[.tpc14-swN_input:checked]:[--tint:…]utilities. The arbitrary-property form is the interesting half: a utility class that declares a custom property is how v4 lets markup own runtime state. peer-checked:replaces everyinput:checked + spanselector, andpeer-focus-visible:replaces the focus twins — six selectors become six variants sitting on the elements they style.- The default tint also ships as a utility (
[--tint:oklch(0.6_0.16_25)]) rather than as a rule in the demo's own stylesheet. An unlayered rule outranks every utility regardless of specificity, so a CSS fallback would have silently frozen the picker on red — the layering trap that catches most hand-written-CSS-plus-Tailwind ports.--swstayed an inline arbitrary property per label rather than becoming a theme colour, because a swatch value is data, not a design token. Four theme entries for four demo colourways would pollute the utility namespace. - The
:nth-childhooks the original used inside:has()became explicittpc14-swNclasses. Reordering swatches no longer silently reassigns colours. - Enhancement over the CSS original: size chips grew to a full 44×44 hit box, each swatch gained an
sr-onlycolour name (the original'saria-labelon a decorative span was never announced), and the media tint transition respectsmotion-reduce.
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 Sneaker Card with Colourway & Size Picker
Source: https://codefronts.com/components/tailwind-css-product-cards/tailwind-sneaker-card-colourway-size-picker/
Colourway swatches that retint the whole card and a row of size chips — no JavaScript, and in v4 no bespoke selectors either. peer-checked: styles the swatch from its own hidden radio, and has-[.tpc14-sw1_input:checked]:[--tint:…] on the root turns the picked colour into a live custom property the media and CTA both read.
## HTML
```html
<section class="tpc-14 w-full min-h-svh grid place-items-center bg-tpc14-bg font-tpc14-sans px-5 py-12 [--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw1_input:checked]:[--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw2_input:checked]:[--tint:oklch(0.6_0.14_250)] has-[.tpc14-sw3_input:checked]:[--tint:oklch(0.62_0.15_150)] has-[.tpc14-sw4_input:checked]:[--tint:oklch(0.42_0.03_260)]" aria-label="Sneaker colourway and size picker demo">
<article class="w-[min(340px,100%)] rounded-[20px] overflow-hidden bg-tpc14-card shadow-[0_18px_44px_-24px_oklch(0.25_0.03_260/0.45)]">
<div role="img" aria-label="Product photo: Aurora Runner sneaker" class="aspect-[16/11] bg-[image:linear-gradient(135deg,color-mix(in_oklch,var(--tint)_42%,transparent),color-mix(in_oklch,var(--tint)_16%,transparent)),url('https://images.unsplash.com/photo-1600185365926-3a2ce3cdb9eb?w=800&h=550&fit=crop&q=70')] bg-cover bg-center transition-[background] duration-400 motion-reduce:transition-none"></div>
<div class="px-5 pt-4.5 pb-5">
<p class="font-tpc14-sans text-[11px] font-medium tracking-[0.2em] uppercase text-tpc14-mut">Aerolite</p>
<h3 class="mt-1 text-[20px] font-bold text-tpc14-ink">Aurora Runner</h3>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Colour</legend>
<div class="flex gap-2.5">
<label class="tpc14-sw1 cursor-pointer [--sw:oklch(0.6_0.16_25)]">
<input type="radio" name="tpc14-color" class="peer sr-only" checked>
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Ember red</span>
</label>
<label class="tpc14-sw2 cursor-pointer [--sw:oklch(0.6_0.14_250)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Cobalt blue</span>
</label>
<label class="tpc14-sw3 cursor-pointer [--sw:oklch(0.62_0.15_150)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Pine green</span>
</label>
<label class="tpc14-sw4 cursor-pointer [--sw:oklch(0.42_0.03_260)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Graphite</span>
</label>
</div>
</fieldset>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Size (UK)</legend>
<div class="flex flex-wrap gap-2">
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">7</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only" checked><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">8</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">9</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">10</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">11</span></label>
</div>
</fieldset>
<div class="mt-4.5 flex items-center justify-between gap-3">
<p class="text-[22px] font-bold text-tpc14-ink tabular-nums">$140</p>
<button type="button" class="min-h-11 h-11.5 px-5.5 rounded-xl border-0 cursor-pointer bg-[var(--tint)] text-tpc14-onacc text-[14px] font-bold transition-[background-color,filter] duration-300 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc14-ink focus-visible:outline-offset-2 motion-reduce:transition-none">Add to Cart</button>
</div>
</div>
</article>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpc14-bg: #f0f1f4;
--color-tpc14-bg: oklch(0.95 0.004 265);
--color-tpc14-card: #ffffff;
--color-tpc14-card: oklch(1 0 0);
--color-tpc14-ink: #1b2233;
--color-tpc14-ink: oklch(0.26 0.025 265);
--color-tpc14-mut: #5c6577;
--color-tpc14-mut: oklch(0.5 0.022 265);
--color-tpc14-chipink: #344054;
--color-tpc14-chipink: oklch(0.4 0.03 265);
--color-tpc14-edge: #d0d5dd;
--color-tpc14-edge: oklch(0.86 0.012 265);
--color-tpc14-onacc: #ffffff;
--color-tpc14-onacc: oklch(1 0 0);
--font-tpc14-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* --tint is NOT declared here on purpose. An unlayered rule outranks anything in
@layer utilities, so a fallback written here would permanently beat the
has-[…]:[--tint:…] utilities. The default ships as a utility instead. */
.tpc-14 {
width: 100%;
min-height: 100svh;
}
.tpc-14 ::selection {
background: var(--tint);
color: #fff;
}
```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 Sneaker Card with Colourway & Size Picker
Source: https://codefronts.com/components/tailwind-css-product-cards/tailwind-sneaker-card-colourway-size-picker/
Colourway swatches that retint the whole card and a row of size chips — no JavaScript, and in v4 no bespoke selectors either. peer-checked: styles the swatch from its own hidden radio, and has-[.tpc14-sw1_input:checked]:[--tint:…] on the root turns the picked colour into a live custom property the media and CTA both read.
## HTML
```html
<section class="tpc-14 w-full min-h-svh grid place-items-center bg-tpc14-bg font-tpc14-sans px-5 py-12 [--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw1_input:checked]:[--tint:oklch(0.6_0.16_25)] has-[.tpc14-sw2_input:checked]:[--tint:oklch(0.6_0.14_250)] has-[.tpc14-sw3_input:checked]:[--tint:oklch(0.62_0.15_150)] has-[.tpc14-sw4_input:checked]:[--tint:oklch(0.42_0.03_260)]" aria-label="Sneaker colourway and size picker demo">
<article class="w-[min(340px,100%)] rounded-[20px] overflow-hidden bg-tpc14-card shadow-[0_18px_44px_-24px_oklch(0.25_0.03_260/0.45)]">
<div role="img" aria-label="Product photo: Aurora Runner sneaker" class="aspect-[16/11] bg-[image:linear-gradient(135deg,color-mix(in_oklch,var(--tint)_42%,transparent),color-mix(in_oklch,var(--tint)_16%,transparent)),url('https://images.unsplash.com/photo-1600185365926-3a2ce3cdb9eb?w=800&h=550&fit=crop&q=70')] bg-cover bg-center transition-[background] duration-400 motion-reduce:transition-none"></div>
<div class="px-5 pt-4.5 pb-5">
<p class="font-tpc14-sans text-[11px] font-medium tracking-[0.2em] uppercase text-tpc14-mut">Aerolite</p>
<h3 class="mt-1 text-[20px] font-bold text-tpc14-ink">Aurora Runner</h3>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Colour</legend>
<div class="flex gap-2.5">
<label class="tpc14-sw1 cursor-pointer [--sw:oklch(0.6_0.16_25)]">
<input type="radio" name="tpc14-color" class="peer sr-only" checked>
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Ember red</span>
</label>
<label class="tpc14-sw2 cursor-pointer [--sw:oklch(0.6_0.14_250)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Cobalt blue</span>
</label>
<label class="tpc14-sw3 cursor-pointer [--sw:oklch(0.62_0.15_150)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Pine green</span>
</label>
<label class="tpc14-sw4 cursor-pointer [--sw:oklch(0.42_0.03_260)]">
<input type="radio" name="tpc14-color" class="peer sr-only">
<span aria-hidden="true" class="block size-7 rounded-full bg-[var(--sw)] shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--color-tpc14-edge)] transition-[box-shadow,transform] duration-200 hover:scale-108 peer-checked:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_3px_var(--sw),0_0_0_5px_color-mix(in_oklch,var(--sw)_30%,oklch(1_0_0))] peer-focus-visible:shadow-[0_0_0_2px_var(--color-tpc14-card),0_0_0_4px_var(--color-tpc14-ink)] motion-reduce:transition-none"></span>
<span class="sr-only">Graphite</span>
</label>
</div>
</fieldset>
<fieldset class="mt-4 border-0">
<legend class="mb-2 text-[11px] font-bold tracking-[0.08em] uppercase text-tpc14-mut">Size (UK)</legend>
<div class="flex flex-wrap gap-2">
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">7</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only" checked><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">8</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">9</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">10</span></label>
<label class="cursor-pointer"><input type="radio" name="tpc14-size" class="peer sr-only"><span class="grid place-items-center min-w-11 min-h-11 rounded-[10px] border border-tpc14-edge bg-tpc14-card text-tpc14-chipink text-[14px] font-semibold transition-colors duration-200 hover:border-tpc14-mut peer-checked:bg-tpc14-ink peer-checked:text-tpc14-onacc peer-checked:border-tpc14-ink peer-focus-visible:outline-2 peer-focus-visible:outline-tpc14-ink peer-focus-visible:outline-offset-2 motion-reduce:transition-none">11</span></label>
</div>
</fieldset>
<div class="mt-4.5 flex items-center justify-between gap-3">
<p class="text-[22px] font-bold text-tpc14-ink tabular-nums">$140</p>
<button type="button" class="min-h-11 h-11.5 px-5.5 rounded-xl border-0 cursor-pointer bg-[var(--tint)] text-tpc14-onacc text-[14px] font-bold transition-[background-color,filter] duration-300 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc14-ink focus-visible:outline-offset-2 motion-reduce:transition-none">Add to Cart</button>
</div>
</div>
</article>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpc14-bg: #f0f1f4;
--color-tpc14-bg: oklch(0.95 0.004 265);
--color-tpc14-card: #ffffff;
--color-tpc14-card: oklch(1 0 0);
--color-tpc14-ink: #1b2233;
--color-tpc14-ink: oklch(0.26 0.025 265);
--color-tpc14-mut: #5c6577;
--color-tpc14-mut: oklch(0.5 0.022 265);
--color-tpc14-chipink: #344054;
--color-tpc14-chipink: oklch(0.4 0.03 265);
--color-tpc14-edge: #d0d5dd;
--color-tpc14-edge: oklch(0.86 0.012 265);
--color-tpc14-onacc: #ffffff;
--color-tpc14-onacc: oklch(1 0 0);
--font-tpc14-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* --tint is NOT declared here on purpose. An unlayered rule outranks anything in
@layer utilities, so a fallback written here would permanently beat the
has-[…]:[--tint:…] utilities. The default ships as a utility instead. */
.tpc-14 {
width: 100%;
min-height: 100svh;
}
.tpc-14 ::selection {
background: var(--tint);
color: #fff;
}
```How this works
Two levels of state, two variants. A swatch styling itself from its own radio is a sibling relationship:
<label class="tpc14-sw1">
<input type="radio" name="tpc14-color" class="peer sr-only" checked>
<span class="size-7 rounded-full peer-checked:shadow-[…]"></span>
</label>But the card's tint is an ancestor relationship, which is what :has() is for — and v4 exposes it as a variant:
<section class="has-[.tpc14-sw1_input:checked]:[--tint:oklch(0.6_0.16_25)] …">The second half of that class, [--tint:…], is an arbitrary property rather than an arbitrary value: it declares a custom property from a utility. Four of those on the root are the entire recolouring engine, and every element downstream just reads var(--tint).
Radios, not buttons. Because selection lives in a real radio group, arrow-key navigation, single-choice semantics, form submission and the checked announcement all come free. The card ships zero lines of script and is more accessible than a JavaScript swatch picker usually is.
Make it yours
- New colourway: add a
label.tpc14-swNwith its swatch colour, plus one matchinghas-[…]:[--tint:…]on the root. - In production, switch the
<img>source per colourway instead of tinting — the tint is a demo stand-in for real photography. - Size chips: restyle to
rounded-fullcircles, or swap the scale for US/EU numbers. Keep them one radio group. - Show the picked colour's name by adding a
peer-checked:block hiddenspan inside each label. - Declare the default colourway with the
[--tint:…]utility on the section, never as a plain CSS rule — unlayered CSS beats layered utilities and the picker stops responding. - Retheme the static chrome through
--color-tpc14-ink; the live colour is--tintand is meant to be overwritten.
Gotchas — read before shipping
sr-only, nothiddenorinvisible. Adisplay:noneradio cannot be focused, which removes keyboard selection entirely.peeronly reaches later siblings. The<input>must precede the<span>it styles.- Each
has-[…]variant needs its own class hook on the label (tpc14-sw1…).:nth-childinside ahas-[]works but breaks the moment the order changes. - Colour alone is not a selected state. The checked swatch gains a ring and the checked chip inverts, so the choice survives colour-blindness and greyscale.
- Both radio groups need a
nameand a<legend>or the fieldset is announced as an unlabelled group. :has()is the hard floor here — Safari 15.4 and older get the default tint and a fully working picker, which is the right degradation.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
:has() requires Chrome 105+, Safari 15.4+, Firefox 121+. Without it the swatches still select and announce correctly; only the live card tint stays on its default.