Pure TailwindMIT licensed

Tailwind Material Design Text Fields and Form Controls

Filled and outlined text fields with true floating labels, plus select, textarea, checkbox, radio, switch and slider — and every one of them is peer-driven, so the label, the underline and the helper text all read the input's own state. The port also upgrades validation: user-invalid: shows errors only after the user has actually interacted, which the CSS version's :invalid could not do.

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="tmd-04 w-full min-h-svh bg-tmd04-bg font-tmd04-sans text-tmd04-onsurface px-5 py-9" aria-label="Material Design form controls demo">
  <form class="w-[min(720px,100%)] mx-auto grid gap-6" novalidate>
    <header class="grid gap-1.5">
      <h2 class="text-[26px] font-medium tracking-[-0.01em]">Text fields &amp; form controls</h2>
      <p class="text-sm text-tmd04-onvariant">Floating labels on <code class="font-tmd04-mono text-[12px]">peer-[:not(:placeholder-shown)]:</code>, errors on <code class="font-tmd04-mono text-[12px]">user-invalid:</code>.</p>
    </header>

    <div class="grid gap-5 p-5.5 rounded-3xl bg-tmd04-surface shadow-tmd04-1">
      <span class="font-tmd04-mono text-[11px] uppercase tracking-[0.1em] text-tmd04-onvariant">Text fields</span>

      <div class="grid gap-1.5">
        <div class="relative rounded-t-md bg-tmd04-fieldfill after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-tmd04-primary after:scale-x-0 after:transition-transform after:duration-200 focus-within:after:scale-x-100 motion-reduce:after:transition-none">
          <input type="text" id="tmd04-name" placeholder=" " autocomplete="name" class="peer w-full min-h-14 pt-6 pb-2 px-4 bg-transparent border-0 border-b border-tmd04-outline rounded-t-md font-tmd04-sans text-[15px] text-tmd04-onsurface outline-none">
          <label for="tmd04-name" class="absolute left-4 top-4 pointer-events-none text-[15px] text-tmd04-onvariant transition-all duration-200 peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-tmd04-primary peer-[:not(:placeholder-shown)]:top-1.5 peer-[:not(:placeholder-shown)]:text-[11px] motion-reduce:transition-none">Full name</label>
        </div>
        <p class="px-4 text-xs text-tmd04-onvariant">Filled variant &mdash; underline scales in from the centre on focus.</p>
      </div>

      <div class="group/email grid gap-1.5">
        <div class="relative">
          <input type="email" id="tmd04-email" placeholder=" " required autocomplete="email" aria-describedby="tmd04-email-help" class="peer w-full min-h-14 pt-6 pb-2 px-4 bg-transparent rounded-md border border-tmd04-outline font-tmd04-sans text-[15px] text-tmd04-onsurface outline-none transition-[border-color,box-shadow] duration-150 focus:border-tmd04-primary focus:shadow-[inset_0_0_0_1px_var(--color-tmd04-primary)] user-invalid:border-tmd04-error user-invalid:shadow-[inset_0_0_0_1px_var(--color-tmd04-error)]">
          <label for="tmd04-email" class="absolute left-4 top-4 pointer-events-none text-[15px] text-tmd04-onvariant transition-all duration-200 peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-tmd04-primary peer-[:not(:placeholder-shown)]:top-1.5 peer-[:not(:placeholder-shown)]:text-[11px] peer-user-invalid:text-tmd04-error motion-reduce:transition-none">Email address</label>
          <span class="font-tmd04-icon absolute right-4 top-4 text-[20px] leading-none text-tmd04-error opacity-0 peer-user-invalid:opacity-100" aria-hidden="true">error</span>
        </div>
        <p id="tmd04-email-help" class="px-4 text-xs text-tmd04-onvariant group-has-[:user-invalid]/email:text-tmd04-error">Outlined variant &mdash; type something invalid, then leave the field.</p>
      </div>

      <div class="grid gap-1.5">
        <div class="relative">
          <textarea id="tmd04-notes" rows="3" placeholder=" " class="peer w-full pt-6 pb-2 px-4 bg-transparent rounded-md border border-tmd04-outline font-tmd04-sans text-[15px] leading-6 text-tmd04-onsurface outline-none resize-y transition-colors duration-150 focus:border-tmd04-primary"></textarea>
          <label for="tmd04-notes" class="absolute left-4 top-4 pointer-events-none text-[15px] text-tmd04-onvariant transition-all duration-200 peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-tmd04-primary peer-[:not(:placeholder-shown)]:top-1.5 peer-[:not(:placeholder-shown)]:text-[11px] motion-reduce:transition-none">Notes</label>
        </div>
        <div class="flex justify-between px-4 text-xs text-tmd04-onvariant"><span>Supporting text</span><span class="font-tmd04-mono tabular-nums">0 / 240</span></div>
      </div>

      <label class="grid gap-1.5 has-[:disabled]:opacity-38">
        <span class="px-1 text-xs font-medium text-tmd04-onvariant">Region</span>
        <select class="w-full min-h-14 px-4 bg-transparent rounded-md border border-tmd04-outline font-tmd04-sans text-[15px] text-tmd04-onsurface outline-none cursor-pointer transition-colors duration-150 focus:border-tmd04-primary">
          <option>North Atlantic</option>
          <option>Baltic</option>
          <option>Mediterranean</option>
        </select>
      </label>
    </div>

    <div class="grid gap-5 p-5.5 rounded-3xl bg-tmd04-surface shadow-tmd04-1">
      <span class="font-tmd04-mono text-[11px] uppercase tracking-[0.1em] text-tmd04-onvariant">Selection controls</span>
      <div class="grid gap-3 grid-cols-[repeat(auto-fit,minmax(min(220px,100%),1fr))]">
        <div class="grid gap-2">
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="checkbox" checked class="w-5 h-5 accent-tmd04-primary cursor-pointer">Sync offline copies</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="checkbox" class="w-5 h-5 accent-tmd04-primary cursor-pointer">Include archived</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer has-[:disabled]:opacity-38"><input type="checkbox" disabled class="w-5 h-5 accent-tmd04-primary">Unavailable option</label>
        </div>
        <fieldset class="grid gap-2 border-0 p-0 m-0">
          <legend class="text-xs font-medium text-tmd04-onvariant mb-1">Cadence</legend>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="radio" name="tmd04-cadence" checked class="w-5 h-5 accent-tmd04-primary cursor-pointer">Hourly</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="radio" name="tmd04-cadence" class="w-5 h-5 accent-tmd04-primary cursor-pointer">Daily</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="radio" name="tmd04-cadence" class="w-5 h-5 accent-tmd04-primary cursor-pointer">Weekly</label>
        </fieldset>
        <div class="grid gap-3 content-start">
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer">
            <input type="checkbox" role="switch" checked class="peer sr-only">
            <span class="relative w-13 h-8 rounded-full bg-tmd04-outlinevariant border-2 border-tmd04-outline transition-colors duration-200 peer-checked:bg-tmd04-primary peer-checked:border-tmd04-primary peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tmd04-primary after:absolute after:top-1/2 after:left-1 after:[transform:translate(0,-50%)] after:w-4 after:h-4 after:rounded-full after:bg-tmd04-outline after:transition-[transform,background,width,height] after:duration-200 peer-checked:after:[transform:translate(20px,-50%)] peer-checked:after:w-6 peer-checked:after:h-6 peer-checked:after:bg-tmd04-onprimary motion-reduce:transition-none motion-reduce:after:transition-none" aria-hidden="true"></span>
            Push notifications
          </label>
          <div class="grid gap-1">
            <label for="tmd04-range" class="text-xs font-medium text-tmd04-onvariant">Sampling depth</label>
            <input type="range" id="tmd04-range" min="0" max="100" value="62" class="w-full h-11 accent-tmd04-primary cursor-pointer">
          </div>
        </div>
      </div>
      <div class="flex flex-wrap gap-2 pt-1">
        <button type="submit" class="relative overflow-hidden inline-flex items-center gap-2 min-h-10 px-6 rounded-full border-0 bg-tmd04-primary text-tmd04-onprimary cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tmd04-primary before:absolute before:inset-0 before:bg-current before:opacity-0 before:transition-opacity hover:before:opacity-[0.08] active:before:opacity-[0.12]">Save changes</button>
        <button type="reset" class="relative overflow-hidden min-h-10 px-4 rounded-full border-0 bg-transparent cursor-pointer text-sm font-medium text-tmd04-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tmd04-primary before:absolute before:inset-0 before:bg-current before:opacity-0 before:transition-opacity hover:before:opacity-[0.08]">Reset</button>
      </div>
    </div>
  </form>
</section>
@import "tailwindcss";

@theme {
  --color-tmd04-bg: #f1f8f7;
  --color-tmd04-bg: oklch(0.974 0.008 186.9);
  --color-tmd04-surface: #ffffff;
  --color-tmd04-surface: oklch(1 0 89.9);
  --color-tmd04-fieldfill: #eaf3f1;
  --color-tmd04-fieldfill: oklch(0.957 0.01 181.4);
  --color-tmd04-primary: #00695c;
  --color-tmd04-primary: oklch(0.467 0.084 180.4);
  --color-tmd04-onprimary: #ffffff;
  --color-tmd04-onprimary: oklch(1 0 89.9);
  --color-tmd04-onsurface: #1c1b1f;
  --color-tmd04-onsurface: oklch(0.225 0.008 297.2);
  --color-tmd04-onvariant: #4a5b58;
  --color-tmd04-onvariant: oklch(0.456 0.022 183.5);
  --color-tmd04-outline: #79817f;
  --color-tmd04-outline: oklch(0.596 0.01 179.4);
  --color-tmd04-outlinevariant: #c9d6d3;
  --color-tmd04-outlinevariant: oklch(0.865 0.015 180.6);
  --color-tmd04-error: #b3261e;
  --color-tmd04-error: oklch(0.501 0.178 28.7);
  --font-tmd04-sans: 'Roboto Flex', 'Roboto', system-ui, sans-serif;
  --font-tmd04-mono: 'Roboto Mono', ui-monospace, monospace;
  --font-tmd04-icon: 'Material Symbols Outlined';
  --shadow-tmd04-1: 0 1px 2px 0 rgb(0 0 0 / 0.3), 0 1px 3px 1px rgb(0 0 0 / 0.15);
}

.tmd-04 {
  width: 100%;
  min-height: 100svh;
}

.tmd-04 .font-tmd04-icon {
  font-feature-settings: 'liga';
}

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-tmd04-bg: #f1f8f7;
  --color-tmd04-bg: oklch(0.974 0.008 186.9);
  --color-tmd04-surface: #ffffff;
  --color-tmd04-surface: oklch(1 0 89.9);
  --color-tmd04-fieldfill: #eaf3f1;
  --color-tmd04-fieldfill: oklch(0.957 0.01 181.4);
  --color-tmd04-primary: #00695c;
  --color-tmd04-primary: oklch(0.467 0.084 180.4);
  --color-tmd04-onprimary: #ffffff;
  --color-tmd04-onprimary: oklch(1 0 89.9);
  --color-tmd04-onsurface: #1c1b1f;
  --color-tmd04-onsurface: oklch(0.225 0.008 297.2);
  --color-tmd04-onvariant: #4a5b58;
  --color-tmd04-onvariant: oklch(0.456 0.022 183.5);
  --color-tmd04-outline: #79817f;
  --color-tmd04-outline: oklch(0.596 0.01 179.4);
  --color-tmd04-outlinevariant: #c9d6d3;
  --color-tmd04-outlinevariant: oklch(0.865 0.015 180.6);
  --color-tmd04-error: #b3261e;
  --color-tmd04-error: oklch(0.501 0.178 28.7);
  --font-tmd04-sans: 'Roboto Flex', 'Roboto', system-ui, sans-serif;
  --font-tmd04-mono: 'Roboto Mono', ui-monospace, monospace;
  --font-tmd04-icon: 'Material Symbols Outlined';
  --shadow-tmd04-1: 0 1px 2px 0 rgb(0 0 0 / 0.3), 0 1px 3px 1px rgb(0 0 0 / 0.15);
}

.tmd-04 {
  width: 100%;
  min-height: 100svh;
}

.tmd-04 .font-tmd04-icon {
  font-feature-settings: 'liga';
}
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=Roboto+Flex:opsz,wght@8..144,300..700&family=Roboto+Mono:wght@400;500&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0&display=swap">

What changed in the port

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

  • The label's two up-states stay on the label: peer-focus: and peer-[:not(:placeholder-shown)]:. The CSS version needed two selector blocks per field variant; here one class list covers both fields.
  • user-invalid: is the upgrade the port buys for free — the CSS collection's :invalid painted every required field red on first paint.
  • Native checkbox, radio and range with accent-* beat a rebuilt control: forced-colours mode, mobile hit slop and IME behaviour all keep working.
  • The switch is the only control assembled from utilities, because there is no native switch. It carries role=&quot;switch&quot; so it still announces correctly.
  • has-[:disabled]:opacity-38 lets a wrapper dim itself from its input's state — the :has() era replacing a .is-disabled class on the wrapper.
  • The focus underline is after: on the field wrapper with focus-within:after:scale-x-100, which is shorter than the CSS version's separate .line element.
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 Material Design Text Fields and Form Controls
Source: https://codefronts.com/design-styles/tailwind-material-design-components/tailwind-material-design-text-fields-form-controls/

Filled and outlined text fields with true floating labels, plus select, textarea, checkbox, radio, switch and slider &mdash; and every one of them is peer-driven, so the label, the underline and the helper text all read the input's own state. The port also upgrades validation: user-invalid: shows errors only after the user has actually interacted, which the CSS version's :invalid could not do.
## HTML
```html
<section class="tmd-04 w-full min-h-svh bg-tmd04-bg font-tmd04-sans text-tmd04-onsurface px-5 py-9" aria-label="Material Design form controls demo">
  <form class="w-[min(720px,100%)] mx-auto grid gap-6" novalidate>
    <header class="grid gap-1.5">
      <h2 class="text-[26px] font-medium tracking-[-0.01em]">Text fields &amp; form controls</h2>
      <p class="text-sm text-tmd04-onvariant">Floating labels on <code class="font-tmd04-mono text-[12px]">peer-[:not(:placeholder-shown)]:</code>, errors on <code class="font-tmd04-mono text-[12px]">user-invalid:</code>.</p>
    </header>

    <div class="grid gap-5 p-5.5 rounded-3xl bg-tmd04-surface shadow-tmd04-1">
      <span class="font-tmd04-mono text-[11px] uppercase tracking-[0.1em] text-tmd04-onvariant">Text fields</span>

      <div class="grid gap-1.5">
        <div class="relative rounded-t-md bg-tmd04-fieldfill after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-tmd04-primary after:scale-x-0 after:transition-transform after:duration-200 focus-within:after:scale-x-100 motion-reduce:after:transition-none">
          <input type="text" id="tmd04-name" placeholder=" " autocomplete="name" class="peer w-full min-h-14 pt-6 pb-2 px-4 bg-transparent border-0 border-b border-tmd04-outline rounded-t-md font-tmd04-sans text-[15px] text-tmd04-onsurface outline-none">
          <label for="tmd04-name" class="absolute left-4 top-4 pointer-events-none text-[15px] text-tmd04-onvariant transition-all duration-200 peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-tmd04-primary peer-[:not(:placeholder-shown)]:top-1.5 peer-[:not(:placeholder-shown)]:text-[11px] motion-reduce:transition-none">Full name</label>
        </div>
        <p class="px-4 text-xs text-tmd04-onvariant">Filled variant &mdash; underline scales in from the centre on focus.</p>
      </div>

      <div class="group/email grid gap-1.5">
        <div class="relative">
          <input type="email" id="tmd04-email" placeholder=" " required autocomplete="email" aria-describedby="tmd04-email-help" class="peer w-full min-h-14 pt-6 pb-2 px-4 bg-transparent rounded-md border border-tmd04-outline font-tmd04-sans text-[15px] text-tmd04-onsurface outline-none transition-[border-color,box-shadow] duration-150 focus:border-tmd04-primary focus:shadow-[inset_0_0_0_1px_var(--color-tmd04-primary)] user-invalid:border-tmd04-error user-invalid:shadow-[inset_0_0_0_1px_var(--color-tmd04-error)]">
          <label for="tmd04-email" class="absolute left-4 top-4 pointer-events-none text-[15px] text-tmd04-onvariant transition-all duration-200 peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-tmd04-primary peer-[:not(:placeholder-shown)]:top-1.5 peer-[:not(:placeholder-shown)]:text-[11px] peer-user-invalid:text-tmd04-error motion-reduce:transition-none">Email address</label>
          <span class="font-tmd04-icon absolute right-4 top-4 text-[20px] leading-none text-tmd04-error opacity-0 peer-user-invalid:opacity-100" aria-hidden="true">error</span>
        </div>
        <p id="tmd04-email-help" class="px-4 text-xs text-tmd04-onvariant group-has-[:user-invalid]/email:text-tmd04-error">Outlined variant &mdash; type something invalid, then leave the field.</p>
      </div>

      <div class="grid gap-1.5">
        <div class="relative">
          <textarea id="tmd04-notes" rows="3" placeholder=" " class="peer w-full pt-6 pb-2 px-4 bg-transparent rounded-md border border-tmd04-outline font-tmd04-sans text-[15px] leading-6 text-tmd04-onsurface outline-none resize-y transition-colors duration-150 focus:border-tmd04-primary"></textarea>
          <label for="tmd04-notes" class="absolute left-4 top-4 pointer-events-none text-[15px] text-tmd04-onvariant transition-all duration-200 peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-tmd04-primary peer-[:not(:placeholder-shown)]:top-1.5 peer-[:not(:placeholder-shown)]:text-[11px] motion-reduce:transition-none">Notes</label>
        </div>
        <div class="flex justify-between px-4 text-xs text-tmd04-onvariant"><span>Supporting text</span><span class="font-tmd04-mono tabular-nums">0 / 240</span></div>
      </div>

      <label class="grid gap-1.5 has-[:disabled]:opacity-38">
        <span class="px-1 text-xs font-medium text-tmd04-onvariant">Region</span>
        <select class="w-full min-h-14 px-4 bg-transparent rounded-md border border-tmd04-outline font-tmd04-sans text-[15px] text-tmd04-onsurface outline-none cursor-pointer transition-colors duration-150 focus:border-tmd04-primary">
          <option>North Atlantic</option>
          <option>Baltic</option>
          <option>Mediterranean</option>
        </select>
      </label>
    </div>

    <div class="grid gap-5 p-5.5 rounded-3xl bg-tmd04-surface shadow-tmd04-1">
      <span class="font-tmd04-mono text-[11px] uppercase tracking-[0.1em] text-tmd04-onvariant">Selection controls</span>
      <div class="grid gap-3 grid-cols-[repeat(auto-fit,minmax(min(220px,100%),1fr))]">
        <div class="grid gap-2">
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="checkbox" checked class="w-5 h-5 accent-tmd04-primary cursor-pointer">Sync offline copies</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="checkbox" class="w-5 h-5 accent-tmd04-primary cursor-pointer">Include archived</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer has-[:disabled]:opacity-38"><input type="checkbox" disabled class="w-5 h-5 accent-tmd04-primary">Unavailable option</label>
        </div>
        <fieldset class="grid gap-2 border-0 p-0 m-0">
          <legend class="text-xs font-medium text-tmd04-onvariant mb-1">Cadence</legend>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="radio" name="tmd04-cadence" checked class="w-5 h-5 accent-tmd04-primary cursor-pointer">Hourly</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="radio" name="tmd04-cadence" class="w-5 h-5 accent-tmd04-primary cursor-pointer">Daily</label>
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer"><input type="radio" name="tmd04-cadence" class="w-5 h-5 accent-tmd04-primary cursor-pointer">Weekly</label>
        </fieldset>
        <div class="grid gap-3 content-start">
          <label class="flex items-center gap-3 min-h-11 text-sm cursor-pointer">
            <input type="checkbox" role="switch" checked class="peer sr-only">
            <span class="relative w-13 h-8 rounded-full bg-tmd04-outlinevariant border-2 border-tmd04-outline transition-colors duration-200 peer-checked:bg-tmd04-primary peer-checked:border-tmd04-primary peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tmd04-primary after:absolute after:top-1/2 after:left-1 after:[transform:translate(0,-50%)] after:w-4 after:h-4 after:rounded-full after:bg-tmd04-outline after:transition-[transform,background,width,height] after:duration-200 peer-checked:after:[transform:translate(20px,-50%)] peer-checked:after:w-6 peer-checked:after:h-6 peer-checked:after:bg-tmd04-onprimary motion-reduce:transition-none motion-reduce:after:transition-none" aria-hidden="true"></span>
            Push notifications
          </label>
          <div class="grid gap-1">
            <label for="tmd04-range" class="text-xs font-medium text-tmd04-onvariant">Sampling depth</label>
            <input type="range" id="tmd04-range" min="0" max="100" value="62" class="w-full h-11 accent-tmd04-primary cursor-pointer">
          </div>
        </div>
      </div>
      <div class="flex flex-wrap gap-2 pt-1">
        <button type="submit" class="relative overflow-hidden inline-flex items-center gap-2 min-h-10 px-6 rounded-full border-0 bg-tmd04-primary text-tmd04-onprimary cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tmd04-primary before:absolute before:inset-0 before:bg-current before:opacity-0 before:transition-opacity hover:before:opacity-[0.08] active:before:opacity-[0.12]">Save changes</button>
        <button type="reset" class="relative overflow-hidden min-h-10 px-4 rounded-full border-0 bg-transparent cursor-pointer text-sm font-medium text-tmd04-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tmd04-primary before:absolute before:inset-0 before:bg-current before:opacity-0 before:transition-opacity hover:before:opacity-[0.08]">Reset</button>
      </div>
    </div>
  </form>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tmd04-bg: #f1f8f7;
  --color-tmd04-bg: oklch(0.974 0.008 186.9);
  --color-tmd04-surface: #ffffff;
  --color-tmd04-surface: oklch(1 0 89.9);
  --color-tmd04-fieldfill: #eaf3f1;
  --color-tmd04-fieldfill: oklch(0.957 0.01 181.4);
  --color-tmd04-primary: #00695c;
  --color-tmd04-primary: oklch(0.467 0.084 180.4);
  --color-tmd04-onprimary: #ffffff;
  --color-tmd04-onprimary: oklch(1 0 89.9);
  --color-tmd04-onsurface: #1c1b1f;
  --color-tmd04-onsurface: oklch(0.225 0.008 297.2);
  --color-tmd04-onvariant: #4a5b58;
  --color-tmd04-onvariant: oklch(0.456 0.022 183.5);
  --color-tmd04-outline: #79817f;
  --color-tmd04-outline: oklch(0.596 0.01 179.4);
  --color-tmd04-outlinevariant: #c9d6d3;
  --color-tmd04-outlinevariant: oklch(0.865 0.015 180.6);
  --color-tmd04-error: #b3261e;
  --color-tmd04-error: oklch(0.501 0.178 28.7);
  --font-tmd04-sans: 'Roboto Flex', 'Roboto', system-ui, sans-serif;
  --font-tmd04-mono: 'Roboto Mono', ui-monospace, monospace;
  --font-tmd04-icon: 'Material Symbols Outlined';
  --shadow-tmd04-1: 0 1px 2px 0 rgb(0 0 0 / 0.3), 0 1px 3px 1px rgb(0 0 0 / 0.15);
}

.tmd-04 {
  width: 100%;
  min-height: 100svh;
}

.tmd-04 .font-tmd04-icon {
  font-feature-settings: 'liga';
}
```

How this works

A floating label is two questions. The label sits over the input and flies up when the field is focused or non-empty. CSS has no :has-content, so the second half is the placeholder trick, and Tailwind expresses both as peer variants on the label itself:

<input placeholder=" " class="peer …">
<label class="absolute left-4 top-4 transition-all
  peer-focus:top-1.5 peer-focus:text-[11px] peer-focus:text-tmd04-primary
  peer-[:not(:placeholder-shown)]:top-1.5
  peer-[:not(:placeholder-shown)]:text-[11px]">Full name</label>

The placeholder=" " is load-bearing: without a placeholder the input is always :placeholder-shown, and the label never stays up.

Errors that wait their turn. :invalid matches an empty required field on first paint, so CSS-only Material forms are red before you type. user-invalid: matches only after interaction — one variant, and the form stops shouting.

Native controls, themed not rebuilt. The checkbox, radio and slider keep their real appearance and take accent-tmd04-primary. That preserves Windows high-contrast mode, forced-colours behaviour and mobile hit testing, all of which a appearance-none rebuild throws away. The switch is the one control with no native equivalent, so it is the one control built from a peer.

Make it yours

  • Field height: the input's pt-6 pb-2 and the label's two top-* values move together — three numbers for the whole floating system.
  • For a Material 2 look, delete the outlined variant's rounded-t/border and keep only the filled field's underline.
  • Recolour focus through --color-tmd04-primary; labels, underlines, accents and rings all read it.
  • Swap user-invalid: for aria-invalid: if your framework sets validity server-side.
  • Add a character counter as a <span> in the helper row; the row is already a flex with justify-between.

Gotchas — read before shipping

  • placeholder=&quot; &quot; — a single space — is required on every floating-label input. Empty string or no attribute breaks the up-state.
  • Never use placeholder text as the label. It disappears on input and fails WCAG 3.3.2; the visible <label> with for is the label.
  • peer only matches a preceding sibling, so the input must come before the label in source order — the visual order comes from absolute positioning.
  • Do not appearance-none the checkbox and radio just to colour them; accent-* exists precisely so you keep the native control.
  • A switch needs role=&quot;switch&quot; on the checkbox, or it announces as a checkbox and the on/off metaphor is lost.
  • The slider's focus ring lives on the input, not the track; do not outline-none it without a replacement.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

:user-invalid is Baseline 2023 (Chrome 119, Safari 16.5, Firefox 88); older browsers simply never show the error state, which is a safe degradation. accent-color is Baseline.

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Search CodeFronts

Loading…