22 / 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 Secure Vault Login with user-invalid Error State
An ironclad industrial login for banking and secure-storage: flat steel tones, geometric borders, a lock crest and a crimson error state that shakes once when a field is left invalid. The port upgrades the validation itself — user-invalid: replaces the original's :invalid:not(:focus):not(:placeholder-shown) stack and only fires after the user has actually interacted.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tlf-22 w-full min-h-svh grid place-items-center bg-tlf22-stage font-tlf22-sans p-6">
<form novalidate class="w-[min(390px,100%)] flex flex-col gap-4 p-8 bg-tlf22-steel border border-tlf22-line rounded-xl shadow-[0_30px_70px_-40px_black,inset_0_1px_0_oklch(1_0_0/0.05)]">
<div aria-hidden="true" class="grid place-items-center size-13 rounded-[10px] text-2xl bg-tlf22-crest border-2 border-tlf22-line">🔒</div>
<h1 class="text-xl font-bold text-tlf22-ink">Secure Vault Access</h1>
<p class="-mt-2.5 text-[12.5px] uppercase tracking-[0.06em] text-tlf22-mut">Authorised personnel only.</p>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-acct" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Account ID</label>
<input id="tlf-22-acct" type="text" inputmode="numeric" pattern="[0-9]{6,}" placeholder="000000" required class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Enter a valid 6+ digit account ID.</span></div>
</div>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-pin" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Passphrase</label>
<input id="tlf-22-pin" type="password" autocomplete="current-password" placeholder="••••••••" required minlength="8" class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Passphrase must be at least 8 characters.</span></div>
</div>
<button type="submit" class="h-12 mt-1 rounded-md cursor-pointer bg-tlf22-btn border border-tlf22-acc text-tlf22-btnink text-sm font-bold uppercase tracking-[0.08em] transition-[background-color,scale] duration-150 hover:bg-tlf22-btnhover active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tlf22-ring motion-reduce:transition-colors motion-reduce:active:scale-100">Unlock</button>
</form>
</section><section class="tlf-22 w-full min-h-svh grid place-items-center bg-tlf22-stage font-tlf22-sans p-6">
<form novalidate class="w-[min(390px,100%)] flex flex-col gap-4 p-8 bg-tlf22-steel border border-tlf22-line rounded-xl shadow-[0_30px_70px_-40px_black,inset_0_1px_0_oklch(1_0_0/0.05)]">
<div aria-hidden="true" class="grid place-items-center size-13 rounded-[10px] text-2xl bg-tlf22-crest border-2 border-tlf22-line">🔒</div>
<h1 class="text-xl font-bold text-tlf22-ink">Secure Vault Access</h1>
<p class="-mt-2.5 text-[12.5px] uppercase tracking-[0.06em] text-tlf22-mut">Authorised personnel only.</p>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-acct" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Account ID</label>
<input id="tlf-22-acct" type="text" inputmode="numeric" pattern="[0-9]{6,}" placeholder="000000" required class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Enter a valid 6+ digit account ID.</span></div>
</div>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-pin" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Passphrase</label>
<input id="tlf-22-pin" type="password" autocomplete="current-password" placeholder="••••••••" required minlength="8" class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Passphrase must be at least 8 characters.</span></div>
</div>
<button type="submit" class="h-12 mt-1 rounded-md cursor-pointer bg-tlf22-btn border border-tlf22-acc text-tlf22-btnink text-sm font-bold uppercase tracking-[0.08em] transition-[background-color,scale] duration-150 hover:bg-tlf22-btnhover active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tlf22-ring motion-reduce:transition-colors motion-reduce:active:scale-100">Unlock</button>
</form>
</section>@import "tailwindcss";
@theme {
--color-tlf22-stage: #0d1015;
--color-tlf22-stage: oklch(0.15 0.008 250);
--color-tlf22-steel: #1c222c;
--color-tlf22-steel: oklch(0.23 0.012 250);
--color-tlf22-crest: #141922;
--color-tlf22-crest: oklch(0.19 0.012 250);
--color-tlf22-field: #12161e;
--color-tlf22-field: oklch(0.18 0.012 250);
--color-tlf22-line: #3a4452;
--color-tlf22-line: oklch(0.34 0.018 250);
--color-tlf22-ink: #e8edf3;
--color-tlf22-ink: oklch(0.94 0.008 250);
--color-tlf22-label: #a8b3c4;
--color-tlf22-label: oklch(0.76 0.018 250);
--color-tlf22-mut: #8e9aab;
--color-tlf22-mut: oklch(0.67 0.02 250);
--color-tlf22-ph: #6b7688;
--color-tlf22-ph: oklch(0.55 0.02 250);
--color-tlf22-acc: #5ec4e0;
--color-tlf22-acc: oklch(0.76 0.13 210);
--color-tlf22-halo: #a6dcec;
--color-tlf22-halo: oklch(0.76 0.13 210 / 0.25);
--color-tlf22-ring: #8ed3e7;
--color-tlf22-ring: oklch(0.76 0.13 210 / 0.55);
--color-tlf22-btn: #14323d;
--color-tlf22-btn: oklch(0.76 0.13 210 / 0.16);
--color-tlf22-btnhover: #1a4453;
--color-tlf22-btnhover: oklch(0.76 0.13 210 / 0.26);
--color-tlf22-btnink: #dff2fb;
--color-tlf22-btnink: oklch(0.95 0.03 210);
--color-tlf22-danger: #f28b7d;
--color-tlf22-danger: oklch(0.72 0.16 25);
--color-tlf22-dangerhalo: #e97a6a;
--color-tlf22-dangerhalo: oklch(0.72 0.16 25 / 0.22);
--font-tlf22-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-tlf22-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--animate-tlf22-shake: tlf-22-shake 0.4s ease-in-out 1;
}
@keyframes tlf-22-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}
.tlf-22 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}@import "tailwindcss";
@theme {
--color-tlf22-stage: #0d1015;
--color-tlf22-stage: oklch(0.15 0.008 250);
--color-tlf22-steel: #1c222c;
--color-tlf22-steel: oklch(0.23 0.012 250);
--color-tlf22-crest: #141922;
--color-tlf22-crest: oklch(0.19 0.012 250);
--color-tlf22-field: #12161e;
--color-tlf22-field: oklch(0.18 0.012 250);
--color-tlf22-line: #3a4452;
--color-tlf22-line: oklch(0.34 0.018 250);
--color-tlf22-ink: #e8edf3;
--color-tlf22-ink: oklch(0.94 0.008 250);
--color-tlf22-label: #a8b3c4;
--color-tlf22-label: oklch(0.76 0.018 250);
--color-tlf22-mut: #8e9aab;
--color-tlf22-mut: oklch(0.67 0.02 250);
--color-tlf22-ph: #6b7688;
--color-tlf22-ph: oklch(0.55 0.02 250);
--color-tlf22-acc: #5ec4e0;
--color-tlf22-acc: oklch(0.76 0.13 210);
--color-tlf22-halo: #a6dcec;
--color-tlf22-halo: oklch(0.76 0.13 210 / 0.25);
--color-tlf22-ring: #8ed3e7;
--color-tlf22-ring: oklch(0.76 0.13 210 / 0.55);
--color-tlf22-btn: #14323d;
--color-tlf22-btn: oklch(0.76 0.13 210 / 0.16);
--color-tlf22-btnhover: #1a4453;
--color-tlf22-btnhover: oklch(0.76 0.13 210 / 0.26);
--color-tlf22-btnink: #dff2fb;
--color-tlf22-btnink: oklch(0.95 0.03 210);
--color-tlf22-danger: #f28b7d;
--color-tlf22-danger: oklch(0.72 0.16 25);
--color-tlf22-dangerhalo: #e97a6a;
--color-tlf22-dangerhalo: oklch(0.72 0.16 25 / 0.22);
--font-tlf22-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-tlf22-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--animate-tlf22-shake: tlf-22-shake 0.4s ease-in-out 1;
}
@keyframes tlf-22-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}
.tlf-22 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}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-tlf22-stage: #0d1015;
--color-tlf22-stage: oklch(0.15 0.008 250);
--color-tlf22-steel: #1c222c;
--color-tlf22-steel: oklch(0.23 0.012 250);
--color-tlf22-crest: #141922;
--color-tlf22-crest: oklch(0.19 0.012 250);
--color-tlf22-field: #12161e;
--color-tlf22-field: oklch(0.18 0.012 250);
--color-tlf22-line: #3a4452;
--color-tlf22-line: oklch(0.34 0.018 250);
--color-tlf22-ink: #e8edf3;
--color-tlf22-ink: oklch(0.94 0.008 250);
--color-tlf22-label: #a8b3c4;
--color-tlf22-label: oklch(0.76 0.018 250);
--color-tlf22-mut: #8e9aab;
--color-tlf22-mut: oklch(0.67 0.02 250);
--color-tlf22-ph: #6b7688;
--color-tlf22-ph: oklch(0.55 0.02 250);
--color-tlf22-acc: #5ec4e0;
--color-tlf22-acc: oklch(0.76 0.13 210);
--color-tlf22-halo: #a6dcec;
--color-tlf22-halo: oklch(0.76 0.13 210 / 0.25);
--color-tlf22-ring: #8ed3e7;
--color-tlf22-ring: oklch(0.76 0.13 210 / 0.55);
--color-tlf22-btn: #14323d;
--color-tlf22-btn: oklch(0.76 0.13 210 / 0.16);
--color-tlf22-btnhover: #1a4453;
--color-tlf22-btnhover: oklch(0.76 0.13 210 / 0.26);
--color-tlf22-btnink: #dff2fb;
--color-tlf22-btnink: oklch(0.95 0.03 210);
--color-tlf22-danger: #f28b7d;
--color-tlf22-danger: oklch(0.72 0.16 25);
--color-tlf22-dangerhalo: #e97a6a;
--color-tlf22-dangerhalo: oklch(0.72 0.16 25 / 0.22);
--font-tlf22-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-tlf22-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--animate-tlf22-shake: tlf-22-shake 0.4s ease-in-out 1;
}
@keyframes tlf-22-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}
.tlf-22 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}@import "tailwindcss";
@theme {
--color-tlf22-stage: #0d1015;
--color-tlf22-stage: oklch(0.15 0.008 250);
--color-tlf22-steel: #1c222c;
--color-tlf22-steel: oklch(0.23 0.012 250);
--color-tlf22-crest: #141922;
--color-tlf22-crest: oklch(0.19 0.012 250);
--color-tlf22-field: #12161e;
--color-tlf22-field: oklch(0.18 0.012 250);
--color-tlf22-line: #3a4452;
--color-tlf22-line: oklch(0.34 0.018 250);
--color-tlf22-ink: #e8edf3;
--color-tlf22-ink: oklch(0.94 0.008 250);
--color-tlf22-label: #a8b3c4;
--color-tlf22-label: oklch(0.76 0.018 250);
--color-tlf22-mut: #8e9aab;
--color-tlf22-mut: oklch(0.67 0.02 250);
--color-tlf22-ph: #6b7688;
--color-tlf22-ph: oklch(0.55 0.02 250);
--color-tlf22-acc: #5ec4e0;
--color-tlf22-acc: oklch(0.76 0.13 210);
--color-tlf22-halo: #a6dcec;
--color-tlf22-halo: oklch(0.76 0.13 210 / 0.25);
--color-tlf22-ring: #8ed3e7;
--color-tlf22-ring: oklch(0.76 0.13 210 / 0.55);
--color-tlf22-btn: #14323d;
--color-tlf22-btn: oklch(0.76 0.13 210 / 0.16);
--color-tlf22-btnhover: #1a4453;
--color-tlf22-btnhover: oklch(0.76 0.13 210 / 0.26);
--color-tlf22-btnink: #dff2fb;
--color-tlf22-btnink: oklch(0.95 0.03 210);
--color-tlf22-danger: #f28b7d;
--color-tlf22-danger: oklch(0.72 0.16 25);
--color-tlf22-dangerhalo: #e97a6a;
--color-tlf22-dangerhalo: oklch(0.72 0.16 25 / 0.22);
--font-tlf22-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-tlf22-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--animate-tlf22-shake: tlf-22-shake 0.4s ease-in-out 1;
}
@keyframes tlf-22-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}
.tlf-22 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}<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=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&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=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
user-invalid:is the single best argument in this collection for re-porting an old form: three chained pseudo-classes that approximated "the user got this wrong" become one variant that means exactly that, and the timing is the browser's problem rather than yours.has-user-invalid:on the message wrapper is:has(:user-invalid)— the error text reacts to its sibling field without either element knowing a class name.motion-reduce:user-invalid:animate-nonestacks two variants in the order Tailwind expects (media condition outermost); reversing them compiles to a rule that never matches.- UI change from the CSS original: IBM Plex Sans/Mono replace the system stacks, the crimson lifted from
oklch(0.62 0.2 25)(3.9:1 on steel) to0.72 0.16 25(6.4:1), the steel hairline from#333c49tooklch(0.34)so field edges are visible, and the error messages became live regions rather thanrole="alert"spans that were already in the DOM atopacity:0with no live semantics.
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 Secure Vault Login with user-invalid Error State
Source: https://codefronts.com/components/tailwind-css-login-forms/tailwind-secure-vault-login-user-invalid-error/
An ironclad industrial login for banking and secure-storage: flat steel tones, geometric borders, a lock crest and a crimson error state that shakes once when a field is left invalid. The port upgrades the validation itself — user-invalid: replaces the original's :invalid:not(:focus):not(:placeholder-shown) stack and only fires after the user has actually interacted.
## HTML
```html
<section class="tlf-22 w-full min-h-svh grid place-items-center bg-tlf22-stage font-tlf22-sans p-6">
<form novalidate class="w-[min(390px,100%)] flex flex-col gap-4 p-8 bg-tlf22-steel border border-tlf22-line rounded-xl shadow-[0_30px_70px_-40px_black,inset_0_1px_0_oklch(1_0_0/0.05)]">
<div aria-hidden="true" class="grid place-items-center size-13 rounded-[10px] text-2xl bg-tlf22-crest border-2 border-tlf22-line">🔒</div>
<h1 class="text-xl font-bold text-tlf22-ink">Secure Vault Access</h1>
<p class="-mt-2.5 text-[12.5px] uppercase tracking-[0.06em] text-tlf22-mut">Authorised personnel only.</p>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-acct" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Account ID</label>
<input id="tlf-22-acct" type="text" inputmode="numeric" pattern="[0-9]{6,}" placeholder="000000" required class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Enter a valid 6+ digit account ID.</span></div>
</div>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-pin" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Passphrase</label>
<input id="tlf-22-pin" type="password" autocomplete="current-password" placeholder="••••••••" required minlength="8" class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Passphrase must be at least 8 characters.</span></div>
</div>
<button type="submit" class="h-12 mt-1 rounded-md cursor-pointer bg-tlf22-btn border border-tlf22-acc text-tlf22-btnink text-sm font-bold uppercase tracking-[0.08em] transition-[background-color,scale] duration-150 hover:bg-tlf22-btnhover active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tlf22-ring motion-reduce:transition-colors motion-reduce:active:scale-100">Unlock</button>
</form>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tlf22-stage: #0d1015;
--color-tlf22-stage: oklch(0.15 0.008 250);
--color-tlf22-steel: #1c222c;
--color-tlf22-steel: oklch(0.23 0.012 250);
--color-tlf22-crest: #141922;
--color-tlf22-crest: oklch(0.19 0.012 250);
--color-tlf22-field: #12161e;
--color-tlf22-field: oklch(0.18 0.012 250);
--color-tlf22-line: #3a4452;
--color-tlf22-line: oklch(0.34 0.018 250);
--color-tlf22-ink: #e8edf3;
--color-tlf22-ink: oklch(0.94 0.008 250);
--color-tlf22-label: #a8b3c4;
--color-tlf22-label: oklch(0.76 0.018 250);
--color-tlf22-mut: #8e9aab;
--color-tlf22-mut: oklch(0.67 0.02 250);
--color-tlf22-ph: #6b7688;
--color-tlf22-ph: oklch(0.55 0.02 250);
--color-tlf22-acc: #5ec4e0;
--color-tlf22-acc: oklch(0.76 0.13 210);
--color-tlf22-halo: #a6dcec;
--color-tlf22-halo: oklch(0.76 0.13 210 / 0.25);
--color-tlf22-ring: #8ed3e7;
--color-tlf22-ring: oklch(0.76 0.13 210 / 0.55);
--color-tlf22-btn: #14323d;
--color-tlf22-btn: oklch(0.76 0.13 210 / 0.16);
--color-tlf22-btnhover: #1a4453;
--color-tlf22-btnhover: oklch(0.76 0.13 210 / 0.26);
--color-tlf22-btnink: #dff2fb;
--color-tlf22-btnink: oklch(0.95 0.03 210);
--color-tlf22-danger: #f28b7d;
--color-tlf22-danger: oklch(0.72 0.16 25);
--color-tlf22-dangerhalo: #e97a6a;
--color-tlf22-dangerhalo: oklch(0.72 0.16 25 / 0.22);
--font-tlf22-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-tlf22-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--animate-tlf22-shake: tlf-22-shake 0.4s ease-in-out 1;
}
@keyframes tlf-22-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}
.tlf-22 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
```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 Secure Vault Login with user-invalid Error State
Source: https://codefronts.com/components/tailwind-css-login-forms/tailwind-secure-vault-login-user-invalid-error/
An ironclad industrial login for banking and secure-storage: flat steel tones, geometric borders, a lock crest and a crimson error state that shakes once when a field is left invalid. The port upgrades the validation itself — user-invalid: replaces the original's :invalid:not(:focus):not(:placeholder-shown) stack and only fires after the user has actually interacted.
## HTML
```html
<section class="tlf-22 w-full min-h-svh grid place-items-center bg-tlf22-stage font-tlf22-sans p-6">
<form novalidate class="w-[min(390px,100%)] flex flex-col gap-4 p-8 bg-tlf22-steel border border-tlf22-line rounded-xl shadow-[0_30px_70px_-40px_black,inset_0_1px_0_oklch(1_0_0/0.05)]">
<div aria-hidden="true" class="grid place-items-center size-13 rounded-[10px] text-2xl bg-tlf22-crest border-2 border-tlf22-line">🔒</div>
<h1 class="text-xl font-bold text-tlf22-ink">Secure Vault Access</h1>
<p class="-mt-2.5 text-[12.5px] uppercase tracking-[0.06em] text-tlf22-mut">Authorised personnel only.</p>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-acct" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Account ID</label>
<input id="tlf-22-acct" type="text" inputmode="numeric" pattern="[0-9]{6,}" placeholder="000000" required class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Enter a valid 6+ digit account ID.</span></div>
</div>
<div class="flex flex-col gap-1.5">
<label for="tlf-22-pin" class="text-xs font-bold uppercase tracking-[0.05em] text-tlf22-label">Passphrase</label>
<input id="tlf-22-pin" type="password" autocomplete="current-password" placeholder="••••••••" required minlength="8" class="h-12 px-3.5 rounded-md font-tlf22-mono text-[15px] tracking-[0.08em] text-tlf22-ink bg-tlf22-field border-[1.5px] border-tlf22-line transition-[border-color,box-shadow] duration-150 placeholder:text-tlf22-ph placeholder:tracking-[0.15em] focus-visible:outline-none focus-visible:border-tlf22-acc focus-visible:ring-3 focus-visible:ring-tlf22-halo user-invalid:border-tlf22-danger user-invalid:ring-3 user-invalid:ring-tlf22-dangerhalo user-invalid:animate-tlf22-shake motion-reduce:user-invalid:animate-none">
<div class="h-0 opacity-0 overflow-hidden transition-opacity duration-200 has-user-invalid:h-auto has-user-invalid:opacity-100"><span role="alert" aria-live="polite" class="text-xs font-semibold text-tlf22-danger">Passphrase must be at least 8 characters.</span></div>
</div>
<button type="submit" class="h-12 mt-1 rounded-md cursor-pointer bg-tlf22-btn border border-tlf22-acc text-tlf22-btnink text-sm font-bold uppercase tracking-[0.08em] transition-[background-color,scale] duration-150 hover:bg-tlf22-btnhover active:scale-[0.985] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tlf22-ring motion-reduce:transition-colors motion-reduce:active:scale-100">Unlock</button>
</form>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tlf22-stage: #0d1015;
--color-tlf22-stage: oklch(0.15 0.008 250);
--color-tlf22-steel: #1c222c;
--color-tlf22-steel: oklch(0.23 0.012 250);
--color-tlf22-crest: #141922;
--color-tlf22-crest: oklch(0.19 0.012 250);
--color-tlf22-field: #12161e;
--color-tlf22-field: oklch(0.18 0.012 250);
--color-tlf22-line: #3a4452;
--color-tlf22-line: oklch(0.34 0.018 250);
--color-tlf22-ink: #e8edf3;
--color-tlf22-ink: oklch(0.94 0.008 250);
--color-tlf22-label: #a8b3c4;
--color-tlf22-label: oklch(0.76 0.018 250);
--color-tlf22-mut: #8e9aab;
--color-tlf22-mut: oklch(0.67 0.02 250);
--color-tlf22-ph: #6b7688;
--color-tlf22-ph: oklch(0.55 0.02 250);
--color-tlf22-acc: #5ec4e0;
--color-tlf22-acc: oklch(0.76 0.13 210);
--color-tlf22-halo: #a6dcec;
--color-tlf22-halo: oklch(0.76 0.13 210 / 0.25);
--color-tlf22-ring: #8ed3e7;
--color-tlf22-ring: oklch(0.76 0.13 210 / 0.55);
--color-tlf22-btn: #14323d;
--color-tlf22-btn: oklch(0.76 0.13 210 / 0.16);
--color-tlf22-btnhover: #1a4453;
--color-tlf22-btnhover: oklch(0.76 0.13 210 / 0.26);
--color-tlf22-btnink: #dff2fb;
--color-tlf22-btnink: oklch(0.95 0.03 210);
--color-tlf22-danger: #f28b7d;
--color-tlf22-danger: oklch(0.72 0.16 25);
--color-tlf22-dangerhalo: #e97a6a;
--color-tlf22-dangerhalo: oklch(0.72 0.16 25 / 0.22);
--font-tlf22-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-tlf22-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--animate-tlf22-shake: tlf-22-shake 0.4s ease-in-out 1;
}
@keyframes tlf-22-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}
.tlf-22 {
width: 100%;
min-height: 100svh;
color-scheme: dark;
}
```How this works
user-invalid is the real fix. :invalid matches an empty required field before the user has typed anything, which is why the CSS original needed :not(:focus):not(:placeholder-shown) to avoid shouting at people on page load. :user-invalid is specified to match only after interaction, so the port's condition is one variant and is also more correct.
The shake is one transform animation. 400ms, five keyframes, translateX only, played once. Under motion-reduce: it does not play at all — the crimson border and the announced message carry the information, which is the point of the fallback.
Error copy is revealed, not inserted. The message is always in the DOM at h-0 opacity-0 and expands under has-user-invalid:, so screen readers see a live region that already exists rather than a node appearing mid-announcement.
Make it yours
- Steel and crimson:
--color-tlf22-steel,--color-tlf22-danger. - Shake intensity: the
@keyframesoffsets. - Driving errors from JS instead? Swap
user-invalid:fordata-[error]:and set the attribute — the rest of the class list is unchanged. - The crest is a glyph in a bordered box; drop your brand mark in its place.
Gotchas — read before shipping
- Don't use bare
invalid:in Tailwind for this. It matches before first input and the form screams at a user who has done nothing. - Error messages need
role="alert"— colour is not an announcement. - Keep crimson at AA against the steel surface (
oklch(0.68 0.19 25)here, not a deep red), or the warning is the least readable text on the screen.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
:user-invalid is Chrome 119, Safari 16.5, Firefox 88. Older engines simply never match the error variants, leaving the field in its resting state — no false positives.