<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="pf-i-pan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="13" r="7"/><path d="M17 13h5M7 4c0 1.5 1 1.5 1 3M11 3c0 1.5 1 1.5 1 3"/></symbol>
<symbol id="pf-i-bread" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11a4 4 0 0 1 1-7.8C8 3 9.5 4 12 4s4-1 6-.8A4 4 0 0 1 19 11v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z"/><path d="M9 9l2 2M13 9l2 2"/></symbol>
<symbol id="pf-i-glass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3h10l-.5 6a4.5 4.5 0 0 1-9 0zM12 13.5V21M8 21h8M7.3 7h9.4"/></symbol>
<symbol id="pf-i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></symbol>
<symbol id="pf-i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5L12 13l8.5-6.5"/></symbol>
<symbol id="pf-i-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></symbol>
</svg>
<div><span class="lbl">Topic cards</span>
<section class="pf" aria-labelledby="pn-title">
<form class="pf-form" action="#" method="post" data-nf="pn-done">
<div class="pf-head"><h3 id="pn-title">Pantry & Flame newsletters</h3><p>Choose the emails you want. You can change this from any email we send.</p></div>
<fieldset class="pf-set" aria-describedby="pn-topics-hint pn-topics-err" data-err="pn-topics-err" data-missing="Choose at least one newsletter.">
<legend>What would you like to get?</legend>
<p class="pf-hint" id="pn-topics-hint">Pick one or more.</p>
<div class="pf-topics">
<label class="pf-topic"><input class="pf-box" type="checkbox" name="topics" value="weeknight" checked>
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-pan"/></svg></span><b>Weeknight Dinners</b>
<small>Three 30-minute recipes and the shopping list for them.</small><span class="pf-freq">Mondays</span></label>
<label class="pf-topic"><input class="pf-box" type="checkbox" name="topics" value="baking">
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-bread"/></svg></span><b>The Proving Drawer</b>
<small>Bread and baking, with timings that fit around work.</small><span class="pf-freq">Fridays</span></label>
<label class="pf-topic"><input class="pf-box" type="checkbox" name="topics" value="wine">
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-glass"/></svg></span><b>Cellar Notes</b>
<small>Six bottles under £15 and what to cook with them.</small><span class="pf-freq">Monthly</span></label>
<label class="pf-topic"><input class="pf-box" type="checkbox" name="topics" value="deals">
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-tag"/></svg></span><b>Kit Deals</b>
<small>Tested kitchen kit when it is genuinely discounted.</small><span class="pf-freq">Only when there's one</span></label>
</div>
<p class="pf-err" id="pn-topics-err"></p>
</fieldset>
<div class="pf-email">
<label class="pf-label" for="pn-email">Email address</label>
<div class="pf-row">
<input class="pf-input" id="pn-email" name="email" type="email" autocomplete="email" required aria-describedby="pn-email-err" data-err="pn-email-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="pf-btn" type="submit">Subscribe</button>
</div>
<p class="pf-err" id="pn-email-err"></p>
</div>
</form>
<div class="pf-done" id="pn-done" tabindex="-1" hidden>
<span class="pf-done-ico" aria-hidden="true"><svg><use href="#pf-i-mail"/></svg></span>
<h3>Confirm and you're set</h3>
<p>We sent one confirmation email to <b data-email>you</b> covering every newsletter you picked.</p>
</div>
</section>
</div>
<div class="demo-grid">
<div><span class="lbl">Frequency radio cards</span>
<section class="pf" aria-labelledby="cb-title">
<form class="pf-form" action="#" method="post" data-nf="cb-done">
<div class="pf-head"><h3 id="cb-title">Circuit Brief</h3><p>Hardware and chip news without the hype.</p></div>
<fieldset class="pf-set">
<legend>How often?</legend>
<div class="pf-freqs">
<label class="pf-opt"><input class="pf-box" type="radio" name="frequency" value="daily">
<span class="pf-opt-text"><b>Daily briefing</b><small>Weekdays at 7am</small></span><span class="pf-read">5 min read</span></label>
<label class="pf-opt"><input class="pf-box" type="radio" name="frequency" value="weekly" checked>
<span class="pf-opt-text"><b>Weekly roundup</b><small>Saturday mornings</small></span><span class="pf-read">12 min read</span></label>
<label class="pf-opt"><input class="pf-box" type="radio" name="frequency" value="monthly">
<span class="pf-opt-text"><b>Monthly highlights</b><small>First Sunday of the month</small></span><span class="pf-read">20 min read</span></label>
</div>
</fieldset>
<div class="pf-email">
<label class="pf-label" for="cb-email">Work or personal email</label>
<div class="pf-row">
<input class="pf-input" id="cb-email" name="email" type="email" autocomplete="email" required aria-describedby="cb-email-err" data-err="cb-email-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="pf-btn" type="submit">Subscribe</button>
</div>
<p class="pf-err" id="cb-email-err"></p>
</div>
</form>
<div class="pf-done" id="cb-done" tabindex="-1" hidden>
<span class="pf-done-ico" aria-hidden="true"><svg><use href="#pf-i-mail"/></svg></span>
<h3>Check your inbox</h3>
<p>Confirm <b data-email>your address</b> and your first Circuit Brief arrives on schedule.</p>
</div>
</section>
</div>
<div><span class="lbl">Interest chips with consent</span>
<section class="pf" aria-labelledby="os-title">
<form class="pf-form" action="#" method="post" data-nf="os-done">
<div class="pf-head"><h3 id="os-title">Off Season</h3><p>Quiet places, out of peak, at fair prices.</p></div>
<fieldset class="pf-set" aria-describedby="os-int-err" data-err="os-int-err" data-missing="Choose at least one interest.">
<legend>I'm interested in</legend>
<div class="pf-chips">
<label class="pf-chip"><input type="checkbox" name="interests" value="rail" checked><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Rail journeys</label>
<label class="pf-chip"><input type="checkbox" name="interests" value="hiking" checked><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Hiking</label>
<label class="pf-chip"><input type="checkbox" name="interests" value="cities"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>City breaks</label>
<label class="pf-chip"><input type="checkbox" name="interests" value="food"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Food trips</label>
<label class="pf-chip"><input type="checkbox" name="interests" value="islands"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Islands</label>
<label class="pf-chip"><input type="checkbox" name="interests" value="family"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>With kids</label>
</div>
<p class="pf-err" id="os-int-err"></p>
</fieldset>
<fieldset class="pf-set">
<legend>Send it</legend>
<div class="pf-seg">
<label><input type="radio" name="cadence" value="weekly" checked>Weekly</label>
<label><input type="radio" name="cadence" value="fortnightly">Fortnightly</label>
<label><input type="radio" name="cadence" value="monthly">Monthly</label>
</div>
</fieldset>
<div class="pf-email">
<label class="pf-label" for="os-email">Email address</label>
<input class="pf-input" id="os-email" name="email" type="email" autocomplete="email" required aria-describedby="os-email-err" data-err="os-email-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<p class="pf-err" id="os-email-err"></p>
</div>
<div class="pf-consent">
<input class="pf-box" id="os-consent" name="consent" type="checkbox" required aria-describedby="os-consent-err os-consent-note" data-err="os-consent-err"
data-missing="Tick the box to agree to receive Off Season by email.">
<div><label for="os-consent">I agree to receive Off Season by email. I can unsubscribe at any time.</label>
<p class="pf-hint" id="os-consent-note">See how we handle your data in our <a href="#privacy">privacy notice</a>.</p>
<p class="pf-err" id="os-consent-err"></p></div>
</div>
<button class="pf-btn pf-btn--wide" type="submit">Subscribe</button>
</form>
<div class="pf-done" id="os-done" tabindex="-1" hidden>
<span class="pf-done-ico" aria-hidden="true"><svg><use href="#pf-i-mail"/></svg></span>
<h3>Nearly there</h3>
<p>Open the email we sent to <b data-email>you</b> and confirm. Your picks are saved.</p>
</div>
</section>
</div>
</div>
CSS
/* Preference pickers: topics as checkbox cards, frequency as radio cards, interests as chips.
Every group is a fieldset with a legend; the cards are labels around real inputs, so clicks, Space and arrow
keys work as normal. :has(:checked) lights a card, :has(:focus-visible) draws its focus ring. */
.pf{--pf-ink:#1b1a2e;--pf-muted:#545270;--pf-line:#7c7a96;--pf-soft-line:#dddbea;--pf-accent:#4338ca;--pf-accent-dark:#3730a3;--pf-tint:#eeedfd;
--pf-ring:rgba(67,56,202,.24);--pf-error:#b42318;--pf-ok:#15803d;
background:#fff;color:var(--pf-ink);border-radius:20px;padding:28px;font-size:.92rem;line-height:1.5;box-shadow:0 1px 2px rgba(27,26,46,.06),0 20px 44px -26px rgba(27,26,46,.4)}
.pf a{color:var(--pf-accent-dark);font-weight:600;text-underline-offset:2px}
.pf a:focus-visible{outline:2px solid var(--pf-accent);outline-offset:2px;border-radius:3px}
.pf-head h3{font-size:1.3rem;font-weight:750;letter-spacing:-.02em;line-height:1.25}
.pf-head p{color:var(--pf-muted);margin-top:4px}
.pf-set{border:0;min-width:0;margin-top:20px}
.pf-set legend{font-weight:700;font-size:.95rem;padding:0}
.pf-hint{font-size:.8rem;color:var(--pf-muted);margin-top:2px}
.pf-err{display:flex;gap:7px;align-items:flex-start;font-size:.82rem;font-weight:600;color:var(--pf-error);margin-top:8px}
.pf-err:empty{display:none}
.pf-err::before{content:"!";flex:none;display:grid;place-items:center;width:16px;height:16px;margin-top:2px;border-radius:50%;background:var(--pf-error);color:#fff;font-size:.7rem;font-weight:800}
/* checkbox / radio drawn with appearance:none so the same control works in all three stylesheets */
.pf-box{appearance:none;flex:none;width:20px;height:20px;margin:0;border:1.5px solid var(--pf-line);border-radius:6px;background:#fff center/13px no-repeat;cursor:pointer;
transition:background-color .15s,border-color .15s}
.pf-box[type=radio]{border-radius:50%}
.pf-box:checked{background-color:var(--pf-accent);border-color:var(--pf-accent);
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}
.pf-box[type=radio]:checked{background-image:radial-gradient(circle,#fff 0 3.5px,transparent 4px);background-size:auto}
.pf-box:focus-visible{outline:2px solid var(--pf-accent);outline-offset:2px}
.pf-box.is-invalid{border-color:var(--pf-error)}
/* email row shared by the three pickers */
.pf-email{margin-top:20px}
.pf-label{display:block;font-weight:650;font-size:.86rem;margin-bottom:6px}
.pf-row{display:flex;gap:8px}
.pf-input{display:block;flex:1 1 0;width:100%;min-width:0;font:inherit;color:var(--pf-ink);background:#fff;border:1.5px solid var(--pf-line);border-radius:10px;padding:10px 13px;transition:border-color .15s,box-shadow .15s}
.pf-input:focus{outline:none;border-color:var(--pf-accent);box-shadow:0 0 0 4px var(--pf-ring)}
.pf-input.is-invalid{border-color:var(--pf-error)}
.pf-btn{flex:none;font:inherit;font-weight:650;color:#fff;background:var(--pf-accent);border:0;border-radius:10px;padding:10px 18px;cursor:pointer;transition:background-color .15s}
.pf-btn:hover{background:var(--pf-accent-dark)}
.pf-btn:focus-visible{outline:2px solid var(--pf-accent);outline-offset:3px}
.pf-btn--wide{display:block;width:100%;margin-top:18px}
.pf-done{outline:none;text-align:center;padding:10px 0 4px}
.pf-done[hidden],.pf-form[hidden]{display:none}
.pf-done:focus-visible{outline:2px solid var(--pf-accent);outline-offset:4px;border-radius:12px}
.pf-done-ico{display:grid;place-items:center;width:48px;height:48px;margin:0 auto 10px;border-radius:50%;background:var(--pf-tint);color:var(--pf-accent)}
.pf-done-ico svg{width:24px;height:24px}
.pf-done h3{font-size:1.15rem;font-weight:750}
.pf-done p{color:var(--pf-muted);margin-top:4px}
.pf-done b{color:var(--pf-ink);overflow-wrap:anywhere}
/* 1. Topic cards */
.pf-topics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}
.pf-topic{position:relative;display:grid;grid-template-columns:auto 1fr;gap:4px 14px;align-items:start;padding:16px 16px 14px;border:1.5px solid var(--pf-soft-line);border-radius:14px;
cursor:pointer;transition:border-color .15s,background-color .15s,box-shadow .15s}
.pf-topic:hover{border-color:#b9b6d6}
.pf-topic:has(:checked){border-color:var(--pf-accent);background:var(--pf-tint)}
.pf-topic:has(:focus-visible){box-shadow:0 0 0 4px var(--pf-ring)}
.pf-topic .pf-box{position:absolute;top:14px;right:14px}
.pf-ico{grid-row:span 2;display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:var(--pf-tint);color:var(--pf-accent)}
.pf-topic:has(:checked) .pf-ico{background:var(--pf-accent);color:#fff}
.pf-ico svg{width:22px;height:22px}
.pf-topic b{font-size:.96rem;padding-right:28px}
.pf-topic small{display:block;font-size:.82rem;color:var(--pf-muted)}
.pf-freq{grid-column:2;justify-self:start;margin-top:6px;font-size:.72rem;font-weight:700;color:#3730a3;background:#e3e1fb;border-radius:999px;padding:2px 9px}
/* 2. Frequency radio cards */
.pf-freqs{display:grid;gap:10px;margin-top:12px}
.pf-opt{display:flex;align-items:center;gap:12px;padding:13px 14px;border:1.5px solid var(--pf-soft-line);border-radius:12px;cursor:pointer;transition:border-color .15s,background-color .15s,box-shadow .15s}
.pf-opt:hover{border-color:#b9b6d6}
.pf-opt:has(:checked){border-color:var(--pf-accent);background:var(--pf-tint)}
.pf-opt:has(:focus-visible){box-shadow:0 0 0 4px var(--pf-ring)}
.pf-opt-text{flex:1;min-width:0}
.pf-opt b{display:block;font-size:.94rem}
.pf-opt small{font-size:.8rem;color:var(--pf-muted)}
.pf-read{flex:none;font-size:.76rem;font-weight:600;color:var(--pf-muted);white-space:nowrap}
/* 3. Interest chips, a segmented frequency and a consent box */
.pf-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.pf-chip{position:relative;display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1.5px solid var(--pf-line);border-radius:999px;font-size:.86rem;font-weight:600;cursor:pointer;
transition:background-color .15s,border-color .15s,color .15s}
.pf-chip input{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none}
.pf-chip svg{width:14px;height:14px;display:none}
.pf-chip:hover{border-color:var(--pf-accent)}
.pf-chip:has(:checked){background:var(--pf-accent);border-color:var(--pf-accent);color:#fff}
.pf-chip:has(:checked) svg{display:block}
.pf-chip:has(:focus-visible){outline:2px solid var(--pf-accent);outline-offset:2px}
.pf-seg{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:10px;padding:4px;background:#f0eff7;border:1px solid var(--pf-soft-line);border-radius:12px}
.pf-seg label{position:relative;text-align:center;padding:8px 6px;border-radius:9px;font-size:.86rem;font-weight:600;color:var(--pf-muted);cursor:pointer;transition:background-color .15s,color .15s,box-shadow .15s}
.pf-seg input{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none}
.pf-seg label:has(:checked){background:#fff;color:var(--pf-ink);box-shadow:0 1px 3px rgba(27,26,46,.18)}
.pf-seg label:has(:focus-visible){outline:2px solid var(--pf-accent);outline-offset:1px}
.pf-consent{display:flex;gap:10px;align-items:flex-start;margin-top:16px;font-size:.84rem}
.pf-consent .pf-box{margin-top:1px}
.pf-consent label{cursor:pointer}
@media (max-width:560px){.pf-topics{grid-template-columns:1fr}}
@media (max-width:420px){.pf{padding:22px 18px}.pf-row{flex-direction:column}}
@media (prefers-reduced-motion:reduce){.pf-box,.pf-input,.pf-btn,.pf-topic,.pf-opt,.pf-chip,.pf-seg label{transition:none}}
JavaScript
// Front-end demo only: nothing is sent. On submit the script checks each field or fieldset that has data-err
// (a fieldset needs at least one ticked box), writes the message into the element it points to with
// aria-describedby, and moves focus to the first problem. A valid submit swaps in the success state.
// Without JavaScript the browser still checks the required email and consent fields.
(function () {
function check(el) {
var msg = '';
if (el.tagName === 'FIELDSET') { if (!el.querySelector('input:checked')) msg = el.getAttribute('data-missing'); }
else if (!el.checkValidity()) msg = el.getAttribute(el.validity.valueMissing ? 'data-missing' : 'data-bad');
var err = document.getElementById(el.getAttribute('data-err'));
if (err) err.textContent = msg || '';
el.classList.toggle('is-invalid', !!msg);
if (el.tagName !== 'FIELDSET') { if (msg) el.setAttribute('aria-invalid', 'true'); else el.removeAttribute('aria-invalid'); }
return !msg;
}
document.querySelectorAll('form[data-nf]').forEach(function (form) {
var done = document.getElementById(form.getAttribute('data-nf'));
var email = form.querySelector('input[type=email]');
form.noValidate = true;
form.addEventListener('submit', function (e) {
e.preventDefault();
var first = null;
form.querySelectorAll('[data-err]').forEach(function (el) { if (!check(el) && !first) first = el; });
if (first) { (first.tagName === 'FIELDSET' ? first.querySelector('input') : first).focus(); return; }
if (!done) return;
done.querySelectorAll('[data-email]').forEach(function (n) { n.textContent = email.value; });
form.hidden = true; done.hidden = false; done.focus();
});
// once a field shows an error, re-check it while it is being fixed
['input', 'change'].forEach(function (type) {
form.addEventListener(type, function (e) { var el = e.target.closest('.is-invalid'); if (el) check(el); });
});
});
})();
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="pf-i-pan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="13" r="7"/><path d="M17 13h5M7 4c0 1.5 1 1.5 1 3M11 3c0 1.5 1 1.5 1 3"/></symbol>
<symbol id="pf-i-bread" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11a4 4 0 0 1 1-7.8C8 3 9.5 4 12 4s4-1 6-.8A4 4 0 0 1 19 11v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z"/><path d="M9 9l2 2M13 9l2 2"/></symbol>
<symbol id="pf-i-glass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3h10l-.5 6a4.5 4.5 0 0 1-9 0zM12 13.5V21M8 21h8M7.3 7h9.4"/></symbol>
<symbol id="pf-i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></symbol>
<symbol id="pf-i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5L12 13l8.5-6.5"/></symbol>
<symbol id="pf-i-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></symbol>
</svg>
<div><span class="lbl">Topic cards</span>
<section class="card pf" aria-labelledby="pn-title"><div class="card-body">
<form action="#" method="post" data-nf="pn-done">
<div class="pf-head"><h3 id="pn-title">Pantry & Flame newsletters</h3><p>Choose the emails you want. You can change this from any email we send.</p></div>
<fieldset class="pf-set" aria-describedby="pn-topics-hint pn-topics-err" data-err="pn-topics-err" data-missing="Choose at least one newsletter.">
<legend>What would you like to get?</legend>
<p class="form-text" id="pn-topics-hint">Pick one or more.</p>
<div class="pf-topics">
<label class="pf-topic"><input class="form-check-input" type="checkbox" name="topics" value="weeknight" checked>
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-pan"/></svg></span><b>Weeknight Dinners</b>
<small>Three 30-minute recipes and the shopping list for them.</small><span class="badge rounded-pill">Mondays</span></label>
<label class="pf-topic"><input class="form-check-input" type="checkbox" name="topics" value="baking">
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-bread"/></svg></span><b>The Proving Drawer</b>
<small>Bread and baking, with timings that fit around work.</small><span class="badge rounded-pill">Fridays</span></label>
<label class="pf-topic"><input class="form-check-input" type="checkbox" name="topics" value="wine">
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-glass"/></svg></span><b>Cellar Notes</b>
<small>Six bottles under £15 and what to cook with them.</small><span class="badge rounded-pill">Monthly</span></label>
<label class="pf-topic"><input class="form-check-input" type="checkbox" name="topics" value="deals">
<span class="pf-ico" aria-hidden="true"><svg><use href="#pf-i-tag"/></svg></span><b>Kit Deals</b>
<small>Tested kitchen kit when it is genuinely discounted.</small><span class="badge rounded-pill">Only when there's one</span></label>
</div>
<p class="pf-err" id="pn-topics-err"></p>
</fieldset>
<div class="pf-email">
<label class="form-label" for="pn-email">Email address</label>
<div class="d-flex gap-2 pf-row">
<input class="form-control" id="pn-email" name="email" type="email" autocomplete="email" required aria-describedby="pn-email-err" data-err="pn-email-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="btn pf-btn" type="submit">Subscribe</button>
</div>
<p class="pf-err" id="pn-email-err"></p>
</div>
</form>
<div class="pf-done" id="pn-done" tabindex="-1" hidden>
<span class="pf-done-ico" aria-hidden="true"><svg><use href="#pf-i-mail"/></svg></span>
<h3>Confirm and you're set</h3>
<p>We sent one confirmation email to <b data-email>you</b> covering every newsletter you picked.</p>
</div>
</div></section>
</div>
<div class="demo-grid">
<div><span class="lbl">Frequency radio cards</span>
<section class="card pf" aria-labelledby="cb-title"><div class="card-body">
<form action="#" method="post" data-nf="cb-done">
<div class="pf-head"><h3 id="cb-title">Circuit Brief</h3><p>Hardware and chip news without the hype.</p></div>
<fieldset class="pf-set">
<legend>How often?</legend>
<div class="pf-freqs">
<label class="pf-opt"><input class="form-check-input" type="radio" name="frequency" value="daily">
<span class="pf-opt-text"><b>Daily briefing</b><small>Weekdays at 7am</small></span><span class="pf-read">5 min read</span></label>
<label class="pf-opt"><input class="form-check-input" type="radio" name="frequency" value="weekly" checked>
<span class="pf-opt-text"><b>Weekly roundup</b><small>Saturday mornings</small></span><span class="pf-read">12 min read</span></label>
<label class="pf-opt"><input class="form-check-input" type="radio" name="frequency" value="monthly">
<span class="pf-opt-text"><b>Monthly highlights</b><small>First Sunday of the month</small></span><span class="pf-read">20 min read</span></label>
</div>
</fieldset>
<div class="pf-email">
<label class="form-label" for="cb-email">Work or personal email</label>
<div class="d-flex gap-2 pf-row">
<input class="form-control" id="cb-email" name="email" type="email" autocomplete="email" required aria-describedby="cb-email-err" data-err="cb-email-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="btn pf-btn" type="submit">Subscribe</button>
</div>
<p class="pf-err" id="cb-email-err"></p>
</div>
</form>
<div class="pf-done" id="cb-done" tabindex="-1" hidden>
<span class="pf-done-ico" aria-hidden="true"><svg><use href="#pf-i-mail"/></svg></span>
<h3>Check your inbox</h3>
<p>Confirm <b data-email>your address</b> and your first Circuit Brief arrives on schedule.</p>
</div>
</div></section>
</div>
<div><span class="lbl">Interest chips with consent</span>
<section class="card pf" aria-labelledby="os-title"><div class="card-body">
<form action="#" method="post" data-nf="os-done">
<div class="pf-head"><h3 id="os-title">Off Season</h3><p>Quiet places, out of peak, at fair prices.</p></div>
<fieldset class="pf-set" aria-describedby="os-int-err" data-err="os-int-err" data-missing="Choose at least one interest.">
<legend>I'm interested in</legend>
<div class="pf-chips">
<input class="btn-check" type="checkbox" name="interests" value="rail" id="os-i1" checked><label class="btn pf-chip" for="os-i1"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Rail journeys</label>
<input class="btn-check" type="checkbox" name="interests" value="hiking" id="os-i2" checked><label class="btn pf-chip" for="os-i2"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Hiking</label>
<input class="btn-check" type="checkbox" name="interests" value="cities" id="os-i3"><label class="btn pf-chip" for="os-i3"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>City breaks</label>
<input class="btn-check" type="checkbox" name="interests" value="food" id="os-i4"><label class="btn pf-chip" for="os-i4"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Food trips</label>
<input class="btn-check" type="checkbox" name="interests" value="islands" id="os-i5"><label class="btn pf-chip" for="os-i5"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>Islands</label>
<input class="btn-check" type="checkbox" name="interests" value="family" id="os-i6"><label class="btn pf-chip" for="os-i6"><svg aria-hidden="true"><use href="#pf-i-tick"/></svg>With kids</label>
</div>
<p class="pf-err" id="os-int-err"></p>
</fieldset>
<fieldset class="pf-set">
<legend>Send it</legend>
<div class="btn-group pf-seg">
<input class="btn-check" type="radio" name="cadence" value="weekly" id="os-c1" checked><label class="btn" for="os-c1">Weekly</label>
<input class="btn-check" type="radio" name="cadence" value="fortnightly" id="os-c2"><label class="btn" for="os-c2">Fortnightly</label>
<input class="btn-check" type="radio" name="cadence" value="monthly" id="os-c3"><label class="btn" for="os-c3">Monthly</label>
</div>
</fieldset>
<div class="pf-email">
<label class="form-label" for="os-email">Email address</label>
<input class="form-control" id="os-email" name="email" type="email" autocomplete="email" required aria-describedby="os-email-err" data-err="os-email-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<p class="pf-err" id="os-email-err"></p>
</div>
<div class="form-check pf-consent">
<input class="form-check-input" id="os-consent" name="consent" type="checkbox" required aria-describedby="os-consent-err os-consent-note" data-err="os-consent-err"
data-missing="Tick the box to agree to receive Off Season by email.">
<div><label class="form-check-label" for="os-consent">I agree to receive Off Season by email. I can unsubscribe at any time.</label>
<p class="form-text" id="os-consent-note">See how we handle your data in our <a href="#privacy">privacy notice</a>.</p>
<p class="pf-err" id="os-consent-err"></p></div>
</div>
<button class="btn pf-btn w-100 nf-wide" type="submit">Subscribe</button>
</form>
<div class="pf-done" id="os-done" tabindex="-1" hidden>
<span class="pf-done-ico" aria-hidden="true"><svg><use href="#pf-i-mail"/></svg></span>
<h3>Nearly there</h3>
<p>Open the email we sent to <b data-email>you</b> and confirm. Your picks are saved.</p>
</div>
</div></section>
</div>
</div>
// Front-end demo only: nothing is sent. On submit the script checks each field or fieldset that has data-err
// (a fieldset needs at least one ticked box), writes the message into the element it points to with
// aria-describedby, and moves focus to the first problem. A valid submit swaps in the success state.
// Without JavaScript the browser still checks the required email and consent fields.
(function () {
function check(el) {
var msg = '';
if (el.tagName === 'FIELDSET') { if (!el.querySelector('input:checked')) msg = el.getAttribute('data-missing'); }
else if (!el.checkValidity()) msg = el.getAttribute(el.validity.valueMissing ? 'data-missing' : 'data-bad');
var err = document.getElementById(el.getAttribute('data-err'));
if (err) err.textContent = msg || '';
el.classList.toggle('is-invalid', !!msg);
if (el.tagName !== 'FIELDSET') { if (msg) el.setAttribute('aria-invalid', 'true'); else el.removeAttribute('aria-invalid'); }
return !msg;
}
document.querySelectorAll('form[data-nf]').forEach(function (form) {
var done = document.getElementById(form.getAttribute('data-nf'));
var email = form.querySelector('input[type=email]');
form.noValidate = true;
form.addEventListener('submit', function (e) {
e.preventDefault();
var first = null;
form.querySelectorAll('[data-err]').forEach(function (el) { if (!check(el) && !first) first = el; });
if (first) { (first.tagName === 'FIELDSET' ? first.querySelector('input') : first).focus(); return; }
if (!done) return;
done.querySelectorAll('[data-email]').forEach(function (n) { n.textContent = email.value; });
form.hidden = true; done.hidden = false; done.focus();
});
// once a field shows an error, re-check it while it is being fixed
['input', 'change'].forEach(function (type) {
form.addEventListener(type, function (e) { var el = e.target.closest('.is-invalid'); if (el) check(el); });
});
});
})();
// Front-end demo only: nothing is sent. On submit the script checks each field or fieldset that has data-err
// (a fieldset needs at least one ticked box), writes the message into the element it points to with
// aria-describedby, and moves focus to the first problem. A valid submit swaps in the success state.
// Without JavaScript the browser still checks the required email and consent fields.
(function () {
function check(el) {
var msg = '';
if (el.tagName === 'FIELDSET') { if (!el.querySelector('input:checked')) msg = el.getAttribute('data-missing'); }
else if (!el.checkValidity()) msg = el.getAttribute(el.validity.valueMissing ? 'data-missing' : 'data-bad');
var err = document.getElementById(el.getAttribute('data-err'));
if (err) err.textContent = msg || '';
el.classList.toggle('is-invalid', !!msg);
if (el.tagName !== 'FIELDSET') { if (msg) el.setAttribute('aria-invalid', 'true'); else el.removeAttribute('aria-invalid'); }
return !msg;
}
document.querySelectorAll('form[data-nf]').forEach(function (form) {
var done = document.getElementById(form.getAttribute('data-nf'));
var email = form.querySelector('input[type=email]');
form.noValidate = true;
form.addEventListener('submit', function (e) {
e.preventDefault();
var first = null;
form.querySelectorAll('[data-err]').forEach(function (el) { if (!check(el) && !first) first = el; });
if (first) { (first.tagName === 'FIELDSET' ? first.querySelector('input') : first).focus(); return; }
if (!done) return;
done.querySelectorAll('[data-email]').forEach(function (n) { n.textContent = email.value; });
form.hidden = true; done.hidden = false; done.focus();
});
// once a field shows an error, re-check it while it is being fixed
['input', 'change'].forEach(function (type) {
form.addEventListener(type, function (e) { var el = e.target.closest('.is-invalid'); if (el) check(el); });
});
});
})();
About this set
Three sign-up forms that let people choose what they get and how often, instead of one undifferentiated list. Topic cards offers four newsletters from a cooking site as checkbox cards in a two-column grid, each with an icon, a one-line description and a tag for when it arrives; a card lights up when ticked, and the form asks for at least one before it will subscribe. Frequency radio cards lets a tech newsletter reader choose a daily briefing, a weekly roundup or monthly highlights, with the reading time for each. Interest chips with consent combines pill-shaped interest chips, a three-way segmented control for how often, an email field and a GDPR-style consent checkbox that starts unticked, with a link to the privacy notice. Every group is a fieldset with a legend and the cards and chips are labels around real inputs, so mouse, Space and arrow keys all behave natively. A vanilla script checks the email, the consent box and the at-least-one rule, writes each message into its linked element and focuses the first problem; a valid submit shows a single confirmation. Nothing is sent.
What’s included
Checkbox topic cards with an at-least-one rule
Radio cards for frequency with reading time
Interest chips and a segmented control from real inputs
Consent checkbox unticked by default, linked to the privacy notice
Bootstrap version uses .form-check-input, .btn-check chips and a .btn-group
Accessibility
Topics, frequency and interests are each a fieldset with a legend, and every option is a native checkbox or radio inside its label. Error messages are linked with aria-describedby; the email and consent inputs get aria-invalid, and an empty topic group moves focus to its first checkbox. Cards, chips and segments draw a focus ring through :has(:focus-visible), and transitions switch off under prefers-reduced-motion.
Customise it
Change --pf-accent, --pf-accent-dark, --pf-tint and --pf-ring on .pf for the selected colour, and --pf-soft-line for card borders. In Bootstrap the same variables recolour .form-check-input and the .btn-check chips; in Tailwind edit the pf-* colours and the pf-tick image in @theme.