29 / 30
All 30 in 30 Tailwind CSS Login Forms
- 01Tailwind Split-Screen Brand Hero Login Form
- 02Tailwind Multi-Tenant Workspace Account Switcher
- 03Tailwind Floating Label Login Form with peer Variants
- 04Tailwind Web3 Wallet Connect Login Grid
- 05Tailwind Minimalist Fintech Login Card with Layered Shadow
- 06Tailwind OAuth Social Login Form with Brand SVG Buttons
- 07Tailwind Passwordless Magic Link Login with data-state Machine
- 08Tailwind Aurora Glow Login Card with Drifting Blur Blobs
- 09Tailwind Neon Synthwave Arcade Login Form
- 10Tailwind Sliding Login and Sign Up Panel with peer-checked
- 11Tailwind 3D Flip Login Card with Password Reset Back Face
- 12Tailwind Terminal CLI Prompt Login Form
- 13Tailwind Floating Pill Login Widget Docked Over App Content
- 14Tailwind Gradient Border Login Card with background-clip
- 15Tailwind Slide-Over Side Panel Login without JavaScript
- 16Tailwind Social-First Sign Up with Brand-Compliant Buttons
- 17Tailwind Animated Mascot Login that Covers Its Eyes
- 18Tailwind Accessible Compact Login Modal with Scale-In
- 19Tailwind Glassmorphism Login Card with backdrop-filter
- 20Tailwind Multi-Step Login Wizard with Radio-Driven Track
- 21Tailwind Animated Conic Gradient Border Login Card
- 22Tailwind Secure Vault Login with user-invalid Error State
- 23Tailwind Neo-Brutalist Login Form with Hard Offset Shadow
- 24Tailwind Show Hide Password Toggle with aria-pressed
- 25Tailwind OTP Code Input Grid with Auto-Advance
- 26Tailwind Password Strength Meter with data-score Variants
- 27Tailwind Numeric PIN Pad Login for Kiosks and POS
- 28Tailwind Inline Form Validation with user-valid and user-invalid
- 29Tailwind Particle Constellation Login over Canvas
- 30Tailwind Biometric Face ID Prompt with Pulse Ripples
Tailwind Particle Constellation Login over Canvas
A creative-agency login floating over an interactive particle field: drifting stars link into a constellation and lean away from the pointer, while the form sits cleanly above without swallowing clicks. The port's load-bearing utility is pointer-events-none on the canvas — two words that are the difference between a beautiful background and an unusable form.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tlf-29 relative w-full min-h-svh grid place-items-center overflow-hidden font-tlf29-sans p-6 bg-[radial-gradient(circle_at_50%_40%,oklch(0.25_0.06_265),oklch(0.12_0.02_265))]">
<canvas aria-hidden="true" class="tlf-29-canvas absolute inset-0 z-1 w-full h-full pointer-events-none"></canvas>
<form novalidate class="relative z-10 w-[min(380px,100%)] flex flex-col gap-3.5 p-8 rounded-[20px] bg-tlf29-card backdrop-blur-[8px] border border-tlf29-edge text-tlf29-ink shadow-[0_30px_80px_-40px_black]">
<h1 class="text-2xl font-bold tracking-tight">Enter the network</h1>
<p class="-mt-2 mb-1 text-[13.5px] text-tlf29-mut">Sign in to your workspace.</p>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Email</span>
<input type="email" autocomplete="email" placeholder="you@company.com" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Password</span>
<input type="password" autocomplete="current-password" placeholder="••••••••" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<button type="submit" class="mt-1 h-12 rounded-xl border-0 cursor-pointer text-tlf29-btnink text-[15px] font-bold bg-[linear-gradient(90deg,oklch(0.82_0.13_200),oklch(0.79_0.14_230))] transition-[filter,scale] duration-150 hover:brightness-105 active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tlf29-ring motion-reduce:transition-none motion-reduce:active:scale-100">Connect</button>
</form>
</section><section class="tlf-29 relative w-full min-h-svh grid place-items-center overflow-hidden font-tlf29-sans p-6 bg-[radial-gradient(circle_at_50%_40%,oklch(0.25_0.06_265),oklch(0.12_0.02_265))]">
<canvas aria-hidden="true" class="tlf-29-canvas absolute inset-0 z-1 w-full h-full pointer-events-none"></canvas>
<form novalidate class="relative z-10 w-[min(380px,100%)] flex flex-col gap-3.5 p-8 rounded-[20px] bg-tlf29-card backdrop-blur-[8px] border border-tlf29-edge text-tlf29-ink shadow-[0_30px_80px_-40px_black]">
<h1 class="text-2xl font-bold tracking-tight">Enter the network</h1>
<p class="-mt-2 mb-1 text-[13.5px] text-tlf29-mut">Sign in to your workspace.</p>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Email</span>
<input type="email" autocomplete="email" placeholder="you@company.com" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Password</span>
<input type="password" autocomplete="current-password" placeholder="••••••••" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<button type="submit" class="mt-1 h-12 rounded-xl border-0 cursor-pointer text-tlf29-btnink text-[15px] font-bold bg-[linear-gradient(90deg,oklch(0.82_0.13_200),oklch(0.79_0.14_230))] transition-[filter,scale] duration-150 hover:brightness-105 active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tlf29-ring motion-reduce:transition-none motion-reduce:active:scale-100">Connect</button>
</form>
</section>@import "tailwindcss";
@theme {
--color-tlf29-card: #0e162a;
--color-tlf29-card: oklch(0.2 0.03 265 / 0.76);
--color-tlf29-edge: #78a0dc;
--color-tlf29-edge: oklch(0.6 0.05 255 / 0.3);
--color-tlf29-field: #0a1020;
--color-tlf29-field: oklch(0.16 0.025 265 / 0.6);
--color-tlf29-ink: #eaf0fa;
--color-tlf29-ink: oklch(0.95 0.01 255);
--color-tlf29-label: #c3cee2;
--color-tlf29-label: oklch(0.85 0.02 255);
--color-tlf29-mut: #adbbd4;
--color-tlf29-mut: oklch(0.78 0.025 255);
--color-tlf29-ph: #8492ad;
--color-tlf29-ph: oklch(0.63 0.03 255);
--color-tlf29-acc: #6fc9e8;
--color-tlf29-acc: oklch(0.78 0.12 210);
--color-tlf29-halo: #a9dff2;
--color-tlf29-halo: oklch(0.78 0.12 210 / 0.25);
--color-tlf29-ring: #96d6ee;
--color-tlf29-ring: oklch(0.78 0.12 210 / 0.6);
--color-tlf29-btnink: #04121a;
--color-tlf29-btnink: oklch(0.16 0.02 230);
--font-tlf29-sans: 'Space Grotesk', system-ui, sans-serif;
}
.tlf-29 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
/* Safari < 16.4 backdrop-filter prefix. */
.tlf-29 form {
-webkit-backdrop-filter: blur(8px);
}@import "tailwindcss";
@theme {
--color-tlf29-card: #0e162a;
--color-tlf29-card: oklch(0.2 0.03 265 / 0.76);
--color-tlf29-edge: #78a0dc;
--color-tlf29-edge: oklch(0.6 0.05 255 / 0.3);
--color-tlf29-field: #0a1020;
--color-tlf29-field: oklch(0.16 0.025 265 / 0.6);
--color-tlf29-ink: #eaf0fa;
--color-tlf29-ink: oklch(0.95 0.01 255);
--color-tlf29-label: #c3cee2;
--color-tlf29-label: oklch(0.85 0.02 255);
--color-tlf29-mut: #adbbd4;
--color-tlf29-mut: oklch(0.78 0.025 255);
--color-tlf29-ph: #8492ad;
--color-tlf29-ph: oklch(0.63 0.03 255);
--color-tlf29-acc: #6fc9e8;
--color-tlf29-acc: oklch(0.78 0.12 210);
--color-tlf29-halo: #a9dff2;
--color-tlf29-halo: oklch(0.78 0.12 210 / 0.25);
--color-tlf29-ring: #96d6ee;
--color-tlf29-ring: oklch(0.78 0.12 210 / 0.6);
--color-tlf29-btnink: #04121a;
--color-tlf29-btnink: oklch(0.16 0.02 230);
--font-tlf29-sans: 'Space Grotesk', system-ui, sans-serif;
}
.tlf-29 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
/* Safari < 16.4 backdrop-filter prefix. */
.tlf-29 form {
-webkit-backdrop-filter: blur(8px);
}const section = document.querySelector('.tlf-29');
const canvas = section.querySelector('.tlf-29-canvas');
const ctx = canvas.getContext('2d');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const COUNT = 60, LINK = 130;
const mouse = { x: -999, y: -999 };
let w, h, dpr, parts = [];
function resize() {
dpr = Math.min(devicePixelRatio || 1, 2);
w = canvas.clientWidth; h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function init() {
resize();
parts = Array.from({ length: COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4
}));
}
function draw(step) {
ctx.clearRect(0, 0, w, h);
for (const p of parts) {
if (step) {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
const dx = p.x - mouse.x, dy = p.y - mouse.y, d = Math.hypot(dx, dy);
if (d < 100) { p.x += dx / d * 1.2; p.y += dy / d * 1.2; }
}
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(160,210,255,0.85)';
ctx.fill();
}
for (let i = 0; i < parts.length; i++) {
for (let j = i + 1; j < parts.length; j++) {
const a = parts[i], b = parts[j];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (dist < LINK) {
ctx.strokeStyle = 'rgba(130,190,255,' + (1 - dist / LINK) * 0.35 + ')';
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
}
if (step) requestAnimationFrame(() => draw(true));
}
section.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
});
section.addEventListener('pointerleave', () => { mouse.x = mouse.y = -999; });
addEventListener('resize', () => { resize(); if (reduce) draw(false); });
init();
draw(!reduce);const section = document.querySelector('.tlf-29');
const canvas = section.querySelector('.tlf-29-canvas');
const ctx = canvas.getContext('2d');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const COUNT = 60, LINK = 130;
const mouse = { x: -999, y: -999 };
let w, h, dpr, parts = [];
function resize() {
dpr = Math.min(devicePixelRatio || 1, 2);
w = canvas.clientWidth; h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function init() {
resize();
parts = Array.from({ length: COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4
}));
}
function draw(step) {
ctx.clearRect(0, 0, w, h);
for (const p of parts) {
if (step) {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
const dx = p.x - mouse.x, dy = p.y - mouse.y, d = Math.hypot(dx, dy);
if (d < 100) { p.x += dx / d * 1.2; p.y += dy / d * 1.2; }
}
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(160,210,255,0.85)';
ctx.fill();
}
for (let i = 0; i < parts.length; i++) {
for (let j = i + 1; j < parts.length; j++) {
const a = parts[i], b = parts[j];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (dist < LINK) {
ctx.strokeStyle = 'rgba(130,190,255,' + (1 - dist / LINK) * 0.35 + ')';
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
}
if (step) requestAnimationFrame(() => draw(true));
}
section.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
});
section.addEventListener('pointerleave', () => { mouse.x = mouse.y = -999; });
addEventListener('resize', () => { resize(); if (reduce) draw(false); });
init();
draw(!reduce);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-tlf29-card: #0e162a;
--color-tlf29-card: oklch(0.2 0.03 265 / 0.76);
--color-tlf29-edge: #78a0dc;
--color-tlf29-edge: oklch(0.6 0.05 255 / 0.3);
--color-tlf29-field: #0a1020;
--color-tlf29-field: oklch(0.16 0.025 265 / 0.6);
--color-tlf29-ink: #eaf0fa;
--color-tlf29-ink: oklch(0.95 0.01 255);
--color-tlf29-label: #c3cee2;
--color-tlf29-label: oklch(0.85 0.02 255);
--color-tlf29-mut: #adbbd4;
--color-tlf29-mut: oklch(0.78 0.025 255);
--color-tlf29-ph: #8492ad;
--color-tlf29-ph: oklch(0.63 0.03 255);
--color-tlf29-acc: #6fc9e8;
--color-tlf29-acc: oklch(0.78 0.12 210);
--color-tlf29-halo: #a9dff2;
--color-tlf29-halo: oklch(0.78 0.12 210 / 0.25);
--color-tlf29-ring: #96d6ee;
--color-tlf29-ring: oklch(0.78 0.12 210 / 0.6);
--color-tlf29-btnink: #04121a;
--color-tlf29-btnink: oklch(0.16 0.02 230);
--font-tlf29-sans: 'Space Grotesk', system-ui, sans-serif;
}
.tlf-29 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
/* Safari < 16.4 backdrop-filter prefix. */
.tlf-29 form {
-webkit-backdrop-filter: blur(8px);
}@import "tailwindcss";
@theme {
--color-tlf29-card: #0e162a;
--color-tlf29-card: oklch(0.2 0.03 265 / 0.76);
--color-tlf29-edge: #78a0dc;
--color-tlf29-edge: oklch(0.6 0.05 255 / 0.3);
--color-tlf29-field: #0a1020;
--color-tlf29-field: oklch(0.16 0.025 265 / 0.6);
--color-tlf29-ink: #eaf0fa;
--color-tlf29-ink: oklch(0.95 0.01 255);
--color-tlf29-label: #c3cee2;
--color-tlf29-label: oklch(0.85 0.02 255);
--color-tlf29-mut: #adbbd4;
--color-tlf29-mut: oklch(0.78 0.025 255);
--color-tlf29-ph: #8492ad;
--color-tlf29-ph: oklch(0.63 0.03 255);
--color-tlf29-acc: #6fc9e8;
--color-tlf29-acc: oklch(0.78 0.12 210);
--color-tlf29-halo: #a9dff2;
--color-tlf29-halo: oklch(0.78 0.12 210 / 0.25);
--color-tlf29-ring: #96d6ee;
--color-tlf29-ring: oklch(0.78 0.12 210 / 0.6);
--color-tlf29-btnink: #04121a;
--color-tlf29-btnink: oklch(0.16 0.02 230);
--font-tlf29-sans: 'Space Grotesk', system-ui, sans-serif;
}
.tlf-29 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
/* Safari < 16.4 backdrop-filter prefix. */
.tlf-29 form {
-webkit-backdrop-filter: blur(8px);
}<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.
- Canvas paint cannot read
@themetokens, so the star and line colours stay literalrgba()strings in the script. Worth stating plainly: utilities stop at the canvas boundary, and pretending otherwise leads to a colour that is defined twice and updated once. - The reduced-motion path was restructured — the original's loop checked the preference at the bottom, which drew one frame and stopped; the port passes a
stepflag so the static frame is deliberate and the resize handler can redraw it. z-1/z-10andpointer-events-noneare the three utilities that make this demo work at all; everything else is ornament.- UI change from the CSS original: Space Grotesk replaces the system stack, muted copy lifted from
#9fb0cctooklch(0.78 0.025 255), the placeholder from#6b7a97to0.63, and the card's alpha rose from 0.72 to 0.76 so 13.5px text clears AA over the brightest part of the particle field.
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 Particle Constellation Login over Canvas
Source: https://codefronts.com/components/tailwind-css-login-forms/tailwind-particle-constellation-login-canvas/
A creative-agency login floating over an interactive particle field: drifting stars link into a constellation and lean away from the pointer, while the form sits cleanly above without swallowing clicks. The port's load-bearing utility is pointer-events-none on the canvas — two words that are the difference between a beautiful background and an unusable form.
## HTML
```html
<section class="tlf-29 relative w-full min-h-svh grid place-items-center overflow-hidden font-tlf29-sans p-6 bg-[radial-gradient(circle_at_50%_40%,oklch(0.25_0.06_265),oklch(0.12_0.02_265))]">
<canvas aria-hidden="true" class="tlf-29-canvas absolute inset-0 z-1 w-full h-full pointer-events-none"></canvas>
<form novalidate class="relative z-10 w-[min(380px,100%)] flex flex-col gap-3.5 p-8 rounded-[20px] bg-tlf29-card backdrop-blur-[8px] border border-tlf29-edge text-tlf29-ink shadow-[0_30px_80px_-40px_black]">
<h1 class="text-2xl font-bold tracking-tight">Enter the network</h1>
<p class="-mt-2 mb-1 text-[13.5px] text-tlf29-mut">Sign in to your workspace.</p>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Email</span>
<input type="email" autocomplete="email" placeholder="you@company.com" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Password</span>
<input type="password" autocomplete="current-password" placeholder="••••••••" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<button type="submit" class="mt-1 h-12 rounded-xl border-0 cursor-pointer text-tlf29-btnink text-[15px] font-bold bg-[linear-gradient(90deg,oklch(0.82_0.13_200),oklch(0.79_0.14_230))] transition-[filter,scale] duration-150 hover:brightness-105 active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tlf29-ring motion-reduce:transition-none motion-reduce:active:scale-100">Connect</button>
</form>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tlf29-card: #0e162a;
--color-tlf29-card: oklch(0.2 0.03 265 / 0.76);
--color-tlf29-edge: #78a0dc;
--color-tlf29-edge: oklch(0.6 0.05 255 / 0.3);
--color-tlf29-field: #0a1020;
--color-tlf29-field: oklch(0.16 0.025 265 / 0.6);
--color-tlf29-ink: #eaf0fa;
--color-tlf29-ink: oklch(0.95 0.01 255);
--color-tlf29-label: #c3cee2;
--color-tlf29-label: oklch(0.85 0.02 255);
--color-tlf29-mut: #adbbd4;
--color-tlf29-mut: oklch(0.78 0.025 255);
--color-tlf29-ph: #8492ad;
--color-tlf29-ph: oklch(0.63 0.03 255);
--color-tlf29-acc: #6fc9e8;
--color-tlf29-acc: oklch(0.78 0.12 210);
--color-tlf29-halo: #a9dff2;
--color-tlf29-halo: oklch(0.78 0.12 210 / 0.25);
--color-tlf29-ring: #96d6ee;
--color-tlf29-ring: oklch(0.78 0.12 210 / 0.6);
--color-tlf29-btnink: #04121a;
--color-tlf29-btnink: oklch(0.16 0.02 230);
--font-tlf29-sans: 'Space Grotesk', system-ui, sans-serif;
}
.tlf-29 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
/* Safari < 16.4 backdrop-filter prefix. */
.tlf-29 form {
-webkit-backdrop-filter: blur(8px);
}
```
## JavaScript
```js
const section = document.querySelector('.tlf-29');
const canvas = section.querySelector('.tlf-29-canvas');
const ctx = canvas.getContext('2d');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const COUNT = 60, LINK = 130;
const mouse = { x: -999, y: -999 };
let w, h, dpr, parts = [];
function resize() {
dpr = Math.min(devicePixelRatio || 1, 2);
w = canvas.clientWidth; h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function init() {
resize();
parts = Array.from({ length: COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4
}));
}
function draw(step) {
ctx.clearRect(0, 0, w, h);
for (const p of parts) {
if (step) {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
const dx = p.x - mouse.x, dy = p.y - mouse.y, d = Math.hypot(dx, dy);
if (d < 100) { p.x += dx / d * 1.2; p.y += dy / d * 1.2; }
}
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(160,210,255,0.85)';
ctx.fill();
}
for (let i = 0; i < parts.length; i++) {
for (let j = i + 1; j < parts.length; j++) {
const a = parts[i], b = parts[j];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (dist < LINK) {
ctx.strokeStyle = 'rgba(130,190,255,' + (1 - dist / LINK) * 0.35 + ')';
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
}
if (step) requestAnimationFrame(() => draw(true));
}
section.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
});
section.addEventListener('pointerleave', () => { mouse.x = mouse.y = -999; });
addEventListener('resize', () => { resize(); if (reduce) draw(false); });
init();
draw(!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 Particle Constellation Login over Canvas
Source: https://codefronts.com/components/tailwind-css-login-forms/tailwind-particle-constellation-login-canvas/
A creative-agency login floating over an interactive particle field: drifting stars link into a constellation and lean away from the pointer, while the form sits cleanly above without swallowing clicks. The port's load-bearing utility is pointer-events-none on the canvas — two words that are the difference between a beautiful background and an unusable form.
## HTML
```html
<section class="tlf-29 relative w-full min-h-svh grid place-items-center overflow-hidden font-tlf29-sans p-6 bg-[radial-gradient(circle_at_50%_40%,oklch(0.25_0.06_265),oklch(0.12_0.02_265))]">
<canvas aria-hidden="true" class="tlf-29-canvas absolute inset-0 z-1 w-full h-full pointer-events-none"></canvas>
<form novalidate class="relative z-10 w-[min(380px,100%)] flex flex-col gap-3.5 p-8 rounded-[20px] bg-tlf29-card backdrop-blur-[8px] border border-tlf29-edge text-tlf29-ink shadow-[0_30px_80px_-40px_black]">
<h1 class="text-2xl font-bold tracking-tight">Enter the network</h1>
<p class="-mt-2 mb-1 text-[13.5px] text-tlf29-mut">Sign in to your workspace.</p>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Email</span>
<input type="email" autocomplete="email" placeholder="you@company.com" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<label class="flex flex-col gap-1.5"><span class="text-[13px] font-semibold text-tlf29-label">Password</span>
<input type="password" autocomplete="current-password" placeholder="••••••••" required class="h-12 px-3.5 rounded-xl text-[15px] text-tlf29-ink bg-tlf29-field border-[1.5px] border-tlf29-edge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf29-ph focus-visible:outline-none focus-visible:border-tlf29-acc focus-visible:ring-4 focus-visible:ring-tlf29-halo"></label>
<button type="submit" class="mt-1 h-12 rounded-xl border-0 cursor-pointer text-tlf29-btnink text-[15px] font-bold bg-[linear-gradient(90deg,oklch(0.82_0.13_200),oklch(0.79_0.14_230))] transition-[filter,scale] duration-150 hover:brightness-105 active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tlf29-ring motion-reduce:transition-none motion-reduce:active:scale-100">Connect</button>
</form>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tlf29-card: #0e162a;
--color-tlf29-card: oklch(0.2 0.03 265 / 0.76);
--color-tlf29-edge: #78a0dc;
--color-tlf29-edge: oklch(0.6 0.05 255 / 0.3);
--color-tlf29-field: #0a1020;
--color-tlf29-field: oklch(0.16 0.025 265 / 0.6);
--color-tlf29-ink: #eaf0fa;
--color-tlf29-ink: oklch(0.95 0.01 255);
--color-tlf29-label: #c3cee2;
--color-tlf29-label: oklch(0.85 0.02 255);
--color-tlf29-mut: #adbbd4;
--color-tlf29-mut: oklch(0.78 0.025 255);
--color-tlf29-ph: #8492ad;
--color-tlf29-ph: oklch(0.63 0.03 255);
--color-tlf29-acc: #6fc9e8;
--color-tlf29-acc: oklch(0.78 0.12 210);
--color-tlf29-halo: #a9dff2;
--color-tlf29-halo: oklch(0.78 0.12 210 / 0.25);
--color-tlf29-ring: #96d6ee;
--color-tlf29-ring: oklch(0.78 0.12 210 / 0.6);
--color-tlf29-btnink: #04121a;
--color-tlf29-btnink: oklch(0.16 0.02 230);
--font-tlf29-sans: 'Space Grotesk', system-ui, sans-serif;
}
.tlf-29 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
/* Safari < 16.4 backdrop-filter prefix. */
.tlf-29 form {
-webkit-backdrop-filter: blur(8px);
}
```
## JavaScript
```js
const section = document.querySelector('.tlf-29');
const canvas = section.querySelector('.tlf-29-canvas');
const ctx = canvas.getContext('2d');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const COUNT = 60, LINK = 130;
const mouse = { x: -999, y: -999 };
let w, h, dpr, parts = [];
function resize() {
dpr = Math.min(devicePixelRatio || 1, 2);
w = canvas.clientWidth; h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function init() {
resize();
parts = Array.from({ length: COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4
}));
}
function draw(step) {
ctx.clearRect(0, 0, w, h);
for (const p of parts) {
if (step) {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
const dx = p.x - mouse.x, dy = p.y - mouse.y, d = Math.hypot(dx, dy);
if (d < 100) { p.x += dx / d * 1.2; p.y += dy / d * 1.2; }
}
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(160,210,255,0.85)';
ctx.fill();
}
for (let i = 0; i < parts.length; i++) {
for (let j = i + 1; j < parts.length; j++) {
const a = parts[i], b = parts[j];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (dist < LINK) {
ctx.strokeStyle = 'rgba(130,190,255,' + (1 - dist / LINK) * 0.35 + ')';
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
}
if (step) requestAnimationFrame(() => draw(true));
}
section.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
});
section.addEventListener('pointerleave', () => { mouse.x = mouse.y = -999; });
addEventListener('resize', () => { resize(); if (reduce) draw(false); });
init();
draw(!reduce);
```How this works
The canvas must not intercept input. It covers the viewport, so without pointer-events-none every click and focus attempt lands on decoration. The card then takes z-10 and relative to sit above it. This is the pattern's only real trap and it is a two-utility fix.
Reduced motion means one frame, not slower motion. The script checks the media query and calls the draw function once — the constellation is still there, it simply does not move. Turning the loop off entirely would leave an empty black stage.
The animation is decoration and says so. aria-hidden on the canvas; the form beneath is an ordinary accessible form that works identically if the script never runs.
Make it yours
- Particle count, link distance and drift speed: the three constants at the top of the script.
- Star and line colours: the two
rgba()strings in the draw loop (canvas cannot read Tailwind tokens). - Card translucency:
--color-tlf29-card's alpha andbackdrop-blur-[8px]. - Pointer repel radius: the
d < 100check.
Gotchas — read before shipping
pointer-events-noneon the canvas and a higherz-indexon the card. Everything else here is decoration; this is correctness.- Cap the particle count — the link pass is O(n²), and 200 particles is 20,000 distance checks per frame.
- Honour
prefers-reduced-motionwith a static frame. Canvas animation is exempt from no rule just because it is pretty.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Canvas 2D and requestAnimationFrame are universally supported; the form works with or without the animation.