// Sign-up form, front end only: it checks the address and shows the confirmation.
// Nothing is sent. Point the form's action at your mailing-list provider, or post it with fetch() below.
document.querySelectorAll('form[data-signup]').forEach(function (form) {
var input = form.querySelector('input[type="email"]');
var error = form.querySelector('[data-error]');
var done = document.getElementById(form.getAttribute('data-signup'));
form.noValidate = true; // our message replaces the browser bubble; without JS, native validation still works
function clear() { error.textContent = ''; input.removeAttribute('aria-invalid'); }
input.addEventListener('input', clear);
form.addEventListener('submit', function (e) {
e.preventDefault();
var value = input.value.trim();
if (!value || !input.checkValidity()) {
error.textContent = value ? 'Check the address: it needs an @ and a domain, like [email protected].' : 'Enter your email address.';
input.setAttribute('aria-invalid', 'true');
input.focus();
return;
}
clear();
form.hidden = true;
done.hidden = false;
done.focus();
});
});
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<main class="ng d-flex flex-column overflow-hidden" data-bs-theme="dark">
<header class="d-flex flex-wrap align-items-center justify-content-between gap-3 small text-body-secondary">
<a class="ng-logo d-inline-flex align-items-center gap-2 fw-bolder text-uppercase text-body" href="#"><svg width="26" height="26" viewBox="0 0 26 26" fill="none" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="13" cy="13" r="3" fill="#ff4fd8" stroke="none"/><path d="M8 8a7 7 0 0 0 0 10M18 8a7 7 0 0 1 0 10" stroke="#ff4fd8"/><path d="M4.5 4.5a12 12 0 0 0 0 17M21.5 4.5a12 12 0 0 1 0 17" stroke="#3ef0ff"/></svg>Afterglow FM</a>
<p class="m-0">Online radio · Streaming from 1 November</p>
</header>
<section class="ng-body flex-grow-1 d-flex flex-column align-items-center justify-content-center text-center" aria-labelledby="ng-title">
<p class="ng-onair badge rounded-pill d-inline-flex align-items-center gap-2 text-uppercase mb-3">On air soon</p>
<h1 class="ng-title text-uppercase mb-3" id="ng-title">Afterglow</h1>
<p class="ng-lead text-body-secondary mb-4 pb-1">Synthwave, city pop and night-drive records, 24 hours a day, picked by people instead of an algorithm. Tune in when the signal goes live.</p>
<button class="btn btn-neon text-uppercase" type="button" popovertarget="ng-pop">Get the launch signal</button>
<ul class="ng-facts list-unstyled d-flex flex-wrap justify-content-center text-body-secondary mt-4 mb-0">
<li class="d-inline-flex align-items-center gap-2">24/7 stream</li>
<li class="d-inline-flex align-items-center gap-2">No ads, ever</li>
<li class="d-inline-flex align-items-center gap-2">Live DJ sets on Fridays</li>
</ul>
</section>
<div class="ng-scene position-absolute" aria-hidden="true"><span class="ng-sun position-absolute rounded-circle"></span><span class="ng-floor position-absolute top-0 start-0 w-100 h-100"></span></div>
<div class="ng-pop" id="ng-pop" popover role="dialog" aria-labelledby="ng-pop-title">
<button class="btn-close position-absolute" type="button" popovertarget="ng-pop" popovertargetaction="hide" aria-label="Close"></button>
<h2 class="fs-5 fw-bold pe-5 mb-2" id="ng-pop-title">Be there when we go on air</h2>
<p class="text-body-secondary mb-4">One email when the stream starts, with the link and the first week's schedule.</p>
<form data-signup="ng-done" action="#" method="post">
<label class="form-label fw-bold small" for="ng-email">Email address</label>
<div class="d-grid gap-2">
<input class="form-control" id="ng-email" name="email" type="email" autocomplete="email" placeholder="[email protected]" required aria-describedby="ng-err" autofocus>
<button class="btn btn-pink" type="submit">Send me the signal</button>
</div>
<p class="ng-err small mt-2 mb-0" id="ng-err" data-error aria-live="polite"></p>
</form>
<div class="ng-done px-3 py-3" id="ng-done" tabindex="-1" hidden><p class="m-0"><strong>You are on the frequency.</strong> Look out for one email on 1 November.</p></div>
</div>
</main>
// Sign-up form, front end only: it checks the address and shows the confirmation.
// Nothing is sent. Point the form's action at your mailing-list provider, or post it with fetch() below.
document.querySelectorAll('form[data-signup]').forEach(function (form) {
var input = form.querySelector('input[type="email"]');
var error = form.querySelector('[data-error]');
var done = document.getElementById(form.getAttribute('data-signup'));
form.noValidate = true; // our message replaces the browser bubble; without JS, native validation still works
function clear() { error.textContent = ''; input.removeAttribute('aria-invalid'); }
input.addEventListener('input', clear);
form.addEventListener('submit', function (e) {
e.preventDefault();
var value = input.value.trim();
if (!value || !input.checkValidity()) {
error.textContent = value ? 'Check the address: it needs an @ and a domain, like [email protected].' : 'Enter your email address.';
input.setAttribute('aria-invalid', 'true');
input.focus();
return;
}
clear();
form.hidden = true;
done.hidden = false;
done.focus();
});
});
/* The sign-up form lives in a native popover: popovertarget opens it, Esc and a click outside close it,
and the starting: variant (@starting-style) animates it in. No JavaScript is needed to open or close it. */
@theme {
--color-ng-bg: #07050f;
--color-ng-ink: #f5f3ff;
--color-ng-muted: #b8b0d6;
--color-ng-pink: #ff4fd8;
--color-ng-cyan: #3ef0ff;
--color-ng-panel: #120c22;
--color-ng-err: #ff9bb0;
--animate-ng-blink: ng-blink 1.6s ease-in-out infinite;
--animate-ng-flicker: ng-flicker 7s infinite;
--animate-ng-drive: ng-drive 1.2s linear infinite;
@keyframes ng-blink { 50% { opacity: .25; } }
@keyframes ng-flicker { 0%, 91%, 93%, 95%, 100% { opacity: 1; } 92%, 94% { opacity: .55; } }
@keyframes ng-drive { to { background-position: 0 60px, 0 60px; } }
}
JavaScript
// Sign-up form, front end only: it checks the address and shows the confirmation.
// Nothing is sent. Point the form's action at your mailing-list provider, or post it with fetch() below.
document.querySelectorAll('form[data-signup]').forEach(function (form) {
var input = form.querySelector('input[type="email"]');
var error = form.querySelector('[data-error]');
var done = document.getElementById(form.getAttribute('data-signup'));
form.noValidate = true; // our message replaces the browser bubble; without JS, native validation still works
function clear() { error.textContent = ''; input.removeAttribute('aria-invalid'); }
input.addEventListener('input', clear);
form.addEventListener('submit', function (e) {
e.preventDefault();
var value = input.value.trim();
if (!value || !input.checkValidity()) {
error.textContent = value ? 'Check the address: it needs an @ and a domain, like [email protected].' : 'Enter your email address.';
input.setAttribute('aria-invalid', 'true');
input.focus();
return;
}
clear();
form.hidden = true;
done.hidden = false;
done.focus();
});
});
About this set
A dark synthwave coming soon page for an online radio station, and the modern-CSS set in this pack. A glowing magenta wordmark with a slow neon flicker sits above a cyan On air soon tag, a short pitch and three facts, and below them a striped retro sun sinks behind a pink perspective grid that scrolls towards the viewer. The Get the launch signal button opens the sign-up form in a native popover, the kind of modal that usually needs a JavaScript library. Here popovertarget opens it, a close button with popovertargetaction closes it, and Escape or a click on the dimmed backdrop dismisses it, all handled by the browser. @starting-style and transition-behavior: allow-discrete fade and lift it in and out, ::backdrop blurs the page behind, and :user-invalid outlines the field after the visitor has typed a bad address. The only script is the shared sign-up handler, which writes the error message and shows the confirmation; nothing is sent. The sun, grid and flicker are decorative, hidden from assistive technology and still under reduced motion.
What’s included
Signup modal built on the native popover attribute, no JavaScript to open or close
@starting-style entry and allow-discrete exit transitions
Neon wordmark, striped sun and moving perspective grid in CSS
Esc, backdrop click and a labelled close button all dismiss it
All animation stops under prefers-reduced-motion
Accessibility
The popover has role="dialog" and is named by its heading; the input has autofocus so focus lands in the form when it opens, Esc closes it, and the close button has an aria-label. The field has a visible label, an aria-live error linked with aria-describedby and aria-invalid when wrong. Cyan focus outlines are used throughout, all text meets 4.5:1 on the near-black ground, the scene is aria-hidden, and the flicker, blink, grid motion and popover transitions are removed under prefers-reduced-motion.
Customise it
The palette is --ng-pink, --ng-cyan, --ng-bg and --ng-panel on .ng (plain CSS and Bootstrap) or the --color-ng-* theme colours in Tailwind. Grid speed is the ng-drive animation duration and the glow is the text-shadow on the headline.