30 Tailwind CSS Login Forms
28 / 30

All 30 in 30 Tailwind CSS Login Forms

  1. 01Tailwind Split-Screen Brand Hero Login Form
  2. 02Tailwind Multi-Tenant Workspace Account Switcher
  3. 03Tailwind Floating Label Login Form with peer Variants
  4. 04Tailwind Web3 Wallet Connect Login Grid
  5. 05Tailwind Minimalist Fintech Login Card with Layered Shadow
  6. 06Tailwind OAuth Social Login Form with Brand SVG Buttons
  7. 07Tailwind Passwordless Magic Link Login with data-state Machine
  8. 08Tailwind Aurora Glow Login Card with Drifting Blur Blobs
  9. 09Tailwind Neon Synthwave Arcade Login Form
  10. 10Tailwind Sliding Login and Sign Up Panel with peer-checked
  11. 11Tailwind 3D Flip Login Card with Password Reset Back Face
  12. 12Tailwind Terminal CLI Prompt Login Form
  13. 13Tailwind Floating Pill Login Widget Docked Over App Content
  14. 14Tailwind Gradient Border Login Card with background-clip
  15. 15Tailwind Slide-Over Side Panel Login without JavaScript
  16. 16Tailwind Social-First Sign Up with Brand-Compliant Buttons
  17. 17Tailwind Animated Mascot Login that Covers Its Eyes
  18. 18Tailwind Accessible Compact Login Modal with Scale-In
  19. 19Tailwind Glassmorphism Login Card with backdrop-filter
  20. 20Tailwind Multi-Step Login Wizard with Radio-Driven Track
  21. 21Tailwind Animated Conic Gradient Border Login Card
  22. 22Tailwind Secure Vault Login with user-invalid Error State
  23. 23Tailwind Neo-Brutalist Login Form with Hard Offset Shadow
  24. 24Tailwind Show Hide Password Toggle with aria-pressed
  25. 25Tailwind OTP Code Input Grid with Auto-Advance
  26. 26Tailwind Password Strength Meter with data-score Variants
  27. 27Tailwind Numeric PIN Pad Login for Kiosks and POS
  28. 28Tailwind Inline Form Validation with user-valid and user-invalid
  29. 29Tailwind Particle Constellation Login over Canvas
  30. 30Tailwind Biometric Face ID Prompt with Pulse Ripples
Pure TailwindMIT licensed

Tailwind Inline Form Validation with user-valid and user-invalid

Enterprise-grade instant feedback with no JavaScript: each field shows a tick when valid and a red hint when not, driven by native constraint validation. Two v4 variants carry the whole thing — user-valid: and user-invalid: — and the hint reveal uses the grid-template-rows: 0fr → 1fr trick, which is the only way to animate a collapsed auto height.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="tlf-28 w-full min-h-svh grid place-items-center bg-tlf28-stage font-tlf28-sans p-6">
  <form novalidate class="w-[min(400px,100%)] flex flex-col gap-4 p-8 bg-white border border-tlf28-edge rounded-2xl shadow-[0_22px_50px_-38px_oklch(0.25_0.06_265/0.5)]">
    <h1 class="mb-0.5 text-[22px] font-bold text-tlf28-ink">Create account</h1>
    <div class="flex flex-col gap-1.5">
      <label for="tlf-28-email" class="text-[13px] font-semibold text-tlf28-label">Work email</label>
      <div class="relative">
        <input id="tlf-28-email" type="email" autocomplete="email" placeholder="you@company.com" required aria-describedby="tlf-28-email-hint" class="peer w-full h-12 pl-3.5 pr-11 rounded-xl text-[15px] text-tlf28-ink bg-tlf28-field border-[1.5px] border-tlf28-fieldedge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf28-ph focus-visible:outline-none focus-visible:border-tlf28-acc focus-visible:ring-4 focus-visible:ring-tlf28-halo user-valid:border-tlf28-ok user-invalid:border-tlf28-bad">
        <span aria-hidden="true" class="absolute right-3.5 top-1/2 size-5 -translate-y-1/2 scale-40 opacity-0 transition-[opacity,scale] duration-200 peer-user-valid:opacity-100 peer-user-valid:scale-100 peer-user-invalid:opacity-100 peer-user-invalid:scale-100 before:content-[''] before:absolute after:content-[''] after:absolute peer-user-valid:before:left-1.5 peer-user-valid:before:top-0.5 peer-user-valid:before:w-1.5 peer-user-valid:before:h-2.5 peer-user-valid:before:border-r-[2.5px] peer-user-valid:before:border-b-[2.5px] peer-user-valid:before:border-tlf28-ok peer-user-valid:before:rotate-[42deg] peer-user-invalid:before:left-[9px] peer-user-invalid:before:top-1 peer-user-invalid:before:w-[2.5px] peer-user-invalid:before:h-[13px] peer-user-invalid:before:bg-tlf28-bad peer-user-invalid:before:rotate-45 peer-user-invalid:after:left-[9px] peer-user-invalid:after:top-1 peer-user-invalid:after:w-[2.5px] peer-user-invalid:after:h-[13px] peer-user-invalid:after:bg-tlf28-bad peer-user-invalid:after:-rotate-45"></span>
      </div>
      <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-250 has-user-invalid:grid-rows-[1fr] motion-reduce:transition-none"><p id="tlf-28-email-hint" class="overflow-hidden text-xs text-tlf28-bad">Enter a valid email address.</p></div>
    </div>
    <div class="flex flex-col gap-1.5">
      <label for="tlf-28-user" class="text-[13px] font-semibold text-tlf28-label">Username</label>
      <div class="relative">
        <input id="tlf-28-user" type="text" autocomplete="username" placeholder="min 4 characters" required minlength="4" pattern="[a-zA-Z0-9_]+" aria-describedby="tlf-28-user-hint" class="peer w-full h-12 pl-3.5 pr-11 rounded-xl text-[15px] text-tlf28-ink bg-tlf28-field border-[1.5px] border-tlf28-fieldedge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf28-ph focus-visible:outline-none focus-visible:border-tlf28-acc focus-visible:ring-4 focus-visible:ring-tlf28-halo user-valid:border-tlf28-ok user-invalid:border-tlf28-bad">
        <span aria-hidden="true" class="absolute right-3.5 top-1/2 size-5 -translate-y-1/2 scale-40 opacity-0 transition-[opacity,scale] duration-200 peer-user-valid:opacity-100 peer-user-valid:scale-100 peer-user-invalid:opacity-100 peer-user-invalid:scale-100 before:content-[''] before:absolute after:content-[''] after:absolute peer-user-valid:before:left-1.5 peer-user-valid:before:top-0.5 peer-user-valid:before:w-1.5 peer-user-valid:before:h-2.5 peer-user-valid:before:border-r-[2.5px] peer-user-valid:before:border-b-[2.5px] peer-user-valid:before:border-tlf28-ok peer-user-valid:before:rotate-[42deg] peer-user-invalid:before:left-[9px] peer-user-invalid:before:top-1 peer-user-invalid:before:w-[2.5px] peer-user-invalid:before:h-[13px] peer-user-invalid:before:bg-tlf28-bad peer-user-invalid:before:rotate-45 peer-user-invalid:after:left-[9px] peer-user-invalid:after:top-1 peer-user-invalid:after:w-[2.5px] peer-user-invalid:after:h-[13px] peer-user-invalid:after:bg-tlf28-bad peer-user-invalid:after:-rotate-45"></span>
      </div>
      <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-250 has-user-invalid:grid-rows-[1fr] motion-reduce:transition-none"><p id="tlf-28-user-hint" class="overflow-hidden text-xs text-tlf28-bad">4+ letters, numbers or underscore.</p></div>
    </div>
    <div class="flex flex-col gap-1.5">
      <label for="tlf-28-pw" class="text-[13px] font-semibold text-tlf28-label">Password</label>
      <div class="relative">
        <input id="tlf-28-pw" type="password" autocomplete="new-password" placeholder="min 8 characters" required minlength="8" aria-describedby="tlf-28-pw-hint" class="peer w-full h-12 pl-3.5 pr-11 rounded-xl text-[15px] text-tlf28-ink bg-tlf28-field border-[1.5px] border-tlf28-fieldedge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf28-ph focus-visible:outline-none focus-visible:border-tlf28-acc focus-visible:ring-4 focus-visible:ring-tlf28-halo user-valid:border-tlf28-ok user-invalid:border-tlf28-bad">
        <span aria-hidden="true" class="absolute right-3.5 top-1/2 size-5 -translate-y-1/2 scale-40 opacity-0 transition-[opacity,scale] duration-200 peer-user-valid:opacity-100 peer-user-valid:scale-100 peer-user-invalid:opacity-100 peer-user-invalid:scale-100 before:content-[''] before:absolute after:content-[''] after:absolute peer-user-valid:before:left-1.5 peer-user-valid:before:top-0.5 peer-user-valid:before:w-1.5 peer-user-valid:before:h-2.5 peer-user-valid:before:border-r-[2.5px] peer-user-valid:before:border-b-[2.5px] peer-user-valid:before:border-tlf28-ok peer-user-valid:before:rotate-[42deg] peer-user-invalid:before:left-[9px] peer-user-invalid:before:top-1 peer-user-invalid:before:w-[2.5px] peer-user-invalid:before:h-[13px] peer-user-invalid:before:bg-tlf28-bad peer-user-invalid:before:rotate-45 peer-user-invalid:after:left-[9px] peer-user-invalid:after:top-1 peer-user-invalid:after:w-[2.5px] peer-user-invalid:after:h-[13px] peer-user-invalid:after:bg-tlf28-bad peer-user-invalid:after:-rotate-45"></span>
      </div>
      <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-250 has-user-invalid:grid-rows-[1fr] motion-reduce:transition-none"><p id="tlf-28-pw-hint" class="overflow-hidden text-xs text-tlf28-bad">At least 8 characters.</p></div>
    </div>
    <button type="submit" class="h-12 mt-1 rounded-xl border-0 cursor-pointer bg-tlf28-acc text-white text-[15px] font-semibold transition-[filter,scale] duration-150 hover:brightness-110 active:scale-[0.99] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tlf28-ring motion-reduce:transition-none motion-reduce:active:scale-100">Sign up</button>
  </form>
</section>
@import "tailwindcss";

@theme {
  --color-tlf28-stage: #eef1f7;
  --color-tlf28-stage: oklch(0.95 0.008 265);
  --color-tlf28-edge: #e5e9f2;
  --color-tlf28-edge: oklch(0.92 0.008 265);
  --color-tlf28-fieldedge: #d8deeb;
  --color-tlf28-fieldedge: oklch(0.88 0.012 265);
  --color-tlf28-field: #fafbfe;
  --color-tlf28-field: oklch(0.99 0.003 265);
  --color-tlf28-ink: #1a2237;
  --color-tlf28-ink: oklch(0.25 0.04 265);
  --color-tlf28-label: #3d465f;
  --color-tlf28-label: oklch(0.37 0.035 265);
  --color-tlf28-ph: #949db5;
  --color-tlf28-ph: oklch(0.67 0.025 265);
  --color-tlf28-acc: #3d5ec6;
  --color-tlf28-acc: oklch(0.5 0.16 262);
  --color-tlf28-halo: #bfcbee;
  --color-tlf28-halo: oklch(0.5 0.16 262 / 0.16);
  --color-tlf28-ring: #94a5e6;
  --color-tlf28-ring: oklch(0.5 0.16 262 / 0.5);
  --color-tlf28-ok: #2b7a52;
  --color-tlf28-ok: oklch(0.5 0.12 155);
  --color-tlf28-bad: #bc3b28;
  --color-tlf28-bad: oklch(0.5 0.18 25);
  --font-tlf28-sans: 'Public Sans', system-ui, sans-serif;
}

.tlf-28 {
  width: 100%;
  min-height: 100svh;
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-tlf28-stage: #eef1f7;
  --color-tlf28-stage: oklch(0.95 0.008 265);
  --color-tlf28-edge: #e5e9f2;
  --color-tlf28-edge: oklch(0.92 0.008 265);
  --color-tlf28-fieldedge: #d8deeb;
  --color-tlf28-fieldedge: oklch(0.88 0.012 265);
  --color-tlf28-field: #fafbfe;
  --color-tlf28-field: oklch(0.99 0.003 265);
  --color-tlf28-ink: #1a2237;
  --color-tlf28-ink: oklch(0.25 0.04 265);
  --color-tlf28-label: #3d465f;
  --color-tlf28-label: oklch(0.37 0.035 265);
  --color-tlf28-ph: #949db5;
  --color-tlf28-ph: oklch(0.67 0.025 265);
  --color-tlf28-acc: #3d5ec6;
  --color-tlf28-acc: oklch(0.5 0.16 262);
  --color-tlf28-halo: #bfcbee;
  --color-tlf28-halo: oklch(0.5 0.16 262 / 0.16);
  --color-tlf28-ring: #94a5e6;
  --color-tlf28-ring: oklch(0.5 0.16 262 / 0.5);
  --color-tlf28-ok: #2b7a52;
  --color-tlf28-ok: oklch(0.5 0.12 155);
  --color-tlf28-bad: #bc3b28;
  --color-tlf28-bad: oklch(0.5 0.18 25);
  --font-tlf28-sans: 'Public Sans', system-ui, sans-serif;
}

.tlf-28 {
  width: 100%;
  min-height: 100svh;
}
3Fonts used by this demooptional — skip if you have your own type stack
<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=Public+Sans:wght@400;500;600;700&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The mark span's class list is the longest in the collection, and it is the clearest place where a real codebase should extract a component: one element carrying a tick pose and a cross pose in variants is expressive but not maintainable in triplicate.
  • grid-rows-[0fr] → has-user-invalid:grid-rows-[1fr] is worth memorising — it is the answer to "animate to auto height" in every framework, not just this one.
  • peer-user-valid: and peer-user-invalid: compose two v4 features (the peer relationship and the interaction-gated validity pseudo-classes) into one variant, which is the port's neatest single line.
  • UI change from the CSS original: Public Sans replaces the system stack, both state colours darkened to oklch(0.5) so the hint text itself passes 4.5:1 (the original's 0.62 0.2 25 hint was 3.8:1 on white), and fields went 48px with the mark's reserved padding recalculated to match.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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 Inline Form Validation with user-valid and user-invalid
Source: https://codefronts.com/components/tailwind-css-login-forms/tailwind-inline-form-validation-user-valid-invalid/

Enterprise-grade instant feedback with no JavaScript: each field shows a tick when valid and a red hint when not, driven by native constraint validation. Two v4 variants carry the whole thing &mdash; user-valid: and user-invalid: &mdash; and the hint reveal uses the grid-template-rows: 0fr &rarr; 1fr trick, which is the only way to animate a collapsed auto height.
## HTML
```html
<section class="tlf-28 w-full min-h-svh grid place-items-center bg-tlf28-stage font-tlf28-sans p-6">
  <form novalidate class="w-[min(400px,100%)] flex flex-col gap-4 p-8 bg-white border border-tlf28-edge rounded-2xl shadow-[0_22px_50px_-38px_oklch(0.25_0.06_265/0.5)]">
    <h1 class="mb-0.5 text-[22px] font-bold text-tlf28-ink">Create account</h1>
    <div class="flex flex-col gap-1.5">
      <label for="tlf-28-email" class="text-[13px] font-semibold text-tlf28-label">Work email</label>
      <div class="relative">
        <input id="tlf-28-email" type="email" autocomplete="email" placeholder="you@company.com" required aria-describedby="tlf-28-email-hint" class="peer w-full h-12 pl-3.5 pr-11 rounded-xl text-[15px] text-tlf28-ink bg-tlf28-field border-[1.5px] border-tlf28-fieldedge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf28-ph focus-visible:outline-none focus-visible:border-tlf28-acc focus-visible:ring-4 focus-visible:ring-tlf28-halo user-valid:border-tlf28-ok user-invalid:border-tlf28-bad">
        <span aria-hidden="true" class="absolute right-3.5 top-1/2 size-5 -translate-y-1/2 scale-40 opacity-0 transition-[opacity,scale] duration-200 peer-user-valid:opacity-100 peer-user-valid:scale-100 peer-user-invalid:opacity-100 peer-user-invalid:scale-100 before:content-[''] before:absolute after:content-[''] after:absolute peer-user-valid:before:left-1.5 peer-user-valid:before:top-0.5 peer-user-valid:before:w-1.5 peer-user-valid:before:h-2.5 peer-user-valid:before:border-r-[2.5px] peer-user-valid:before:border-b-[2.5px] peer-user-valid:before:border-tlf28-ok peer-user-valid:before:rotate-[42deg] peer-user-invalid:before:left-[9px] peer-user-invalid:before:top-1 peer-user-invalid:before:w-[2.5px] peer-user-invalid:before:h-[13px] peer-user-invalid:before:bg-tlf28-bad peer-user-invalid:before:rotate-45 peer-user-invalid:after:left-[9px] peer-user-invalid:after:top-1 peer-user-invalid:after:w-[2.5px] peer-user-invalid:after:h-[13px] peer-user-invalid:after:bg-tlf28-bad peer-user-invalid:after:-rotate-45"></span>
      </div>
      <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-250 has-user-invalid:grid-rows-[1fr] motion-reduce:transition-none"><p id="tlf-28-email-hint" class="overflow-hidden text-xs text-tlf28-bad">Enter a valid email address.</p></div>
    </div>
    <div class="flex flex-col gap-1.5">
      <label for="tlf-28-user" class="text-[13px] font-semibold text-tlf28-label">Username</label>
      <div class="relative">
        <input id="tlf-28-user" type="text" autocomplete="username" placeholder="min 4 characters" required minlength="4" pattern="[a-zA-Z0-9_]+" aria-describedby="tlf-28-user-hint" class="peer w-full h-12 pl-3.5 pr-11 rounded-xl text-[15px] text-tlf28-ink bg-tlf28-field border-[1.5px] border-tlf28-fieldedge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf28-ph focus-visible:outline-none focus-visible:border-tlf28-acc focus-visible:ring-4 focus-visible:ring-tlf28-halo user-valid:border-tlf28-ok user-invalid:border-tlf28-bad">
        <span aria-hidden="true" class="absolute right-3.5 top-1/2 size-5 -translate-y-1/2 scale-40 opacity-0 transition-[opacity,scale] duration-200 peer-user-valid:opacity-100 peer-user-valid:scale-100 peer-user-invalid:opacity-100 peer-user-invalid:scale-100 before:content-[''] before:absolute after:content-[''] after:absolute peer-user-valid:before:left-1.5 peer-user-valid:before:top-0.5 peer-user-valid:before:w-1.5 peer-user-valid:before:h-2.5 peer-user-valid:before:border-r-[2.5px] peer-user-valid:before:border-b-[2.5px] peer-user-valid:before:border-tlf28-ok peer-user-valid:before:rotate-[42deg] peer-user-invalid:before:left-[9px] peer-user-invalid:before:top-1 peer-user-invalid:before:w-[2.5px] peer-user-invalid:before:h-[13px] peer-user-invalid:before:bg-tlf28-bad peer-user-invalid:before:rotate-45 peer-user-invalid:after:left-[9px] peer-user-invalid:after:top-1 peer-user-invalid:after:w-[2.5px] peer-user-invalid:after:h-[13px] peer-user-invalid:after:bg-tlf28-bad peer-user-invalid:after:-rotate-45"></span>
      </div>
      <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-250 has-user-invalid:grid-rows-[1fr] motion-reduce:transition-none"><p id="tlf-28-user-hint" class="overflow-hidden text-xs text-tlf28-bad">4+ letters, numbers or underscore.</p></div>
    </div>
    <div class="flex flex-col gap-1.5">
      <label for="tlf-28-pw" class="text-[13px] font-semibold text-tlf28-label">Password</label>
      <div class="relative">
        <input id="tlf-28-pw" type="password" autocomplete="new-password" placeholder="min 8 characters" required minlength="8" aria-describedby="tlf-28-pw-hint" class="peer w-full h-12 pl-3.5 pr-11 rounded-xl text-[15px] text-tlf28-ink bg-tlf28-field border-[1.5px] border-tlf28-fieldedge transition-[border-color,box-shadow] duration-150 placeholder:text-tlf28-ph focus-visible:outline-none focus-visible:border-tlf28-acc focus-visible:ring-4 focus-visible:ring-tlf28-halo user-valid:border-tlf28-ok user-invalid:border-tlf28-bad">
        <span aria-hidden="true" class="absolute right-3.5 top-1/2 size-5 -translate-y-1/2 scale-40 opacity-0 transition-[opacity,scale] duration-200 peer-user-valid:opacity-100 peer-user-valid:scale-100 peer-user-invalid:opacity-100 peer-user-invalid:scale-100 before:content-[''] before:absolute after:content-[''] after:absolute peer-user-valid:before:left-1.5 peer-user-valid:before:top-0.5 peer-user-valid:before:w-1.5 peer-user-valid:before:h-2.5 peer-user-valid:before:border-r-[2.5px] peer-user-valid:before:border-b-[2.5px] peer-user-valid:before:border-tlf28-ok peer-user-valid:before:rotate-[42deg] peer-user-invalid:before:left-[9px] peer-user-invalid:before:top-1 peer-user-invalid:before:w-[2.5px] peer-user-invalid:before:h-[13px] peer-user-invalid:before:bg-tlf28-bad peer-user-invalid:before:rotate-45 peer-user-invalid:after:left-[9px] peer-user-invalid:after:top-1 peer-user-invalid:after:w-[2.5px] peer-user-invalid:after:h-[13px] peer-user-invalid:after:bg-tlf28-bad peer-user-invalid:after:-rotate-45"></span>
      </div>
      <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-250 has-user-invalid:grid-rows-[1fr] motion-reduce:transition-none"><p id="tlf-28-pw-hint" class="overflow-hidden text-xs text-tlf28-bad">At least 8 characters.</p></div>
    </div>
    <button type="submit" class="h-12 mt-1 rounded-xl border-0 cursor-pointer bg-tlf28-acc text-white text-[15px] font-semibold transition-[filter,scale] duration-150 hover:brightness-110 active:scale-[0.99] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tlf28-ring motion-reduce:transition-none motion-reduce:active:scale-100">Sign up</button>
  </form>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tlf28-stage: #eef1f7;
  --color-tlf28-stage: oklch(0.95 0.008 265);
  --color-tlf28-edge: #e5e9f2;
  --color-tlf28-edge: oklch(0.92 0.008 265);
  --color-tlf28-fieldedge: #d8deeb;
  --color-tlf28-fieldedge: oklch(0.88 0.012 265);
  --color-tlf28-field: #fafbfe;
  --color-tlf28-field: oklch(0.99 0.003 265);
  --color-tlf28-ink: #1a2237;
  --color-tlf28-ink: oklch(0.25 0.04 265);
  --color-tlf28-label: #3d465f;
  --color-tlf28-label: oklch(0.37 0.035 265);
  --color-tlf28-ph: #949db5;
  --color-tlf28-ph: oklch(0.67 0.025 265);
  --color-tlf28-acc: #3d5ec6;
  --color-tlf28-acc: oklch(0.5 0.16 262);
  --color-tlf28-halo: #bfcbee;
  --color-tlf28-halo: oklch(0.5 0.16 262 / 0.16);
  --color-tlf28-ring: #94a5e6;
  --color-tlf28-ring: oklch(0.5 0.16 262 / 0.5);
  --color-tlf28-ok: #2b7a52;
  --color-tlf28-ok: oklch(0.5 0.12 155);
  --color-tlf28-bad: #bc3b28;
  --color-tlf28-bad: oklch(0.5 0.18 25);
  --font-tlf28-sans: 'Public Sans', system-ui, sans-serif;
}

.tlf-28 {
  width: 100%;
  min-height: 100svh;
}
```

How this works

:user-invalid is why this works. :invalid matches an empty required field on first paint, so a CSS-only validation form built on it greets users with three red errors. :user-valid / :user-invalid only match after interaction — the browser tracks “has the user had a go at this” so your selectors do not have to.

The hint animates from zero to auto. grid-rows-[0fr] to grid-rows-[1fr] on a grid wrapper with an overflow-hidden child is the one reliable way to transition to intrinsic height. height: auto cannot be tweened; max-height guesses.

State is never colour-only. Green border plus a tick, red border plus a cross plus revealed text that is also the field's accessible description. Three channels, which is what an enterprise audit asks for.

Make it yours

  • Constraints: pattern, minlength, type — the CSS reads whatever the browser concludes.
  • State colours: --color-tlf28-ok / --color-tlf28-bad.
  • Swap the CSS tick and cross for inline SVGs; keep aria-hidden on them.
  • Reveal speed: duration-250 on the hint wrapper.

Gotchas — read before shipping

  • Prefer user-invalid: over invalid:. This is the entire difference between a form that helps and a form that scolds.
  • Pair colour with the text hint and aria-describedby — a green border alone is not feedback for everyone.
  • The 0fr → 1fr reveal needs the inner element to be overflow-hidden and the wrapper to be grid; miss either and the hint either jumps or never collapses.

Browser support

ChromeSafariFirefoxEdge
119+16.5+128+119+

:user-valid / :user-invalid are the gate. Older engines match neither, so fields stay in their resting state — no false errors, just no inline feedback.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…