web design
COMPONENT J / CONTACT FORM · UNDERLINE FIELDS + WIPE SUBMIT
How I should address you.
Used only to reply, never passed on.
A sentence or two is enough to start.

GUIDE / CONTACT FORM

Overview

Label-above inputs with a hairline underline, no boxes. The focused field's underline takes the accent, the single color, and the submit reuses the directional-fill button so the form's action matches the rest of the site. Status lands inline, not as a toast.

When to use

The contact section. Wire the submit handler to your endpoint and replace the simulated status with the real response. Keep three fields, name, email, project, anything more belongs in a follow-up email.

Spec

Tokens
--line underline, --accent focus, --grey labels
Contrast
labels 7.7:1, helper 7.7:1, accent focus 11.3:1
A11y
label-for, autocomplete, aria-live status, focus-visible
Motion
underline 300ms, submit wipe reuses dir-button

Anti-patterns

No placeholder-as-label. No boxed inputs, the underline is the field. One accent underline at a time, only the focused field.

<!-- Requires GSAP: https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js -->

<style>
:root{
  --black:#050505;--surface:#0C0C0C;--white:#FFFFFF;--grey:#A3A3A3;--grey-dim:#6B6B6B;--accent:#22D3EE;
  --line:rgba(255,255,255,.16);--line-faint:rgba(255,255,255,.07);
  --font-display:'Space Grotesk',system-ui,sans-serif;--font-mono:'JetBrains Mono',ui-monospace,monospace;
  --dur-fast:150ms;--dur-base:300ms;--ease-out:cubic-bezier(.16,1,.3,1);
}
*{margin:0;padding:0;box-sizing:border-box;border-radius:0!important}
body{background:var(--black);color:var(--white);font-family:var(--font-display);min-height:100vh;padding:64px 48px}
.cf{max-width:560px;margin:0 auto}
.cf-field{margin-bottom:36px}
.cf-label{display:block;font-family:var(--font-mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--grey);margin-bottom:12px}
.cf-input{width:100%;background:transparent;border:none;border-bottom:1px solid var(--line);color:var(--white);font-family:var(--font-display);font-size:1.0625rem;padding:8px 0;transition:border-color var(--dur-base) var(--ease-out)}
.cf-input:focus{outline:none;border-bottom-color:var(--accent)}
.cf-input::placeholder{color:var(--grey-dim)}
.cf-textarea{resize:vertical;min-height:96px;line-height:1.5}
.cf-helper{display:block;font-family:var(--font-mono);font-size:11px;color:var(--grey);margin-top:10px;letter-spacing:.04em}
.cf-submit{
  position:relative;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;
  padding:16px 32px;border:1px solid var(--line);background:transparent;cursor:pointer;
  font-family:var(--font-mono);font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--white);
  transition:transform var(--dur-fast) var(--ease-out);
}
.cf-submit:active{transform:translateY(1px)}
.cf-submit:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.dir-fill{position:absolute;inset:0;z-index:1;background:var(--white);pointer-events:none}
.dir-label-wrap{position:relative;z-index:2;display:inline-block}
.dir-label{display:inline-block;white-space:nowrap}
.dir-label--on{position:absolute;inset:0;color:var(--black);opacity:0}
.cf-status{margin-top:28px;font-family:var(--font-mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--grey);min-height:1.2em}
.cf-status.is-ok{color:var(--white)}
</style>

<form class="cf" id="cf" novalidate>
  <div class="cf-field">
    <label class="cf-label" for="name">Name</label>
    <input class="cf-input" id="name" name="name" type="text" autocomplete="name" required>
    <span class="cf-helper">How I should address you.</span>
  </div>
  <div class="cf-field">
    <label class="cf-label" for="email">Email</label>
    <input class="cf-input" id="email" name="email" type="email" autocomplete="email" required>
    <span class="cf-helper">Used only to reply, never passed on.</span>
  </div>
  <div class="cf-field">
    <label class="cf-label" for="msg">Project</label>
    <textarea class="cf-input cf-textarea" id="msg" name="msg" required></textarea>
    <span class="cf-helper">A sentence or two is enough to start.</span>
  </div>
  <button class="cf-submit" id="submit" type="submit">
    <span class="dir-fill"></span>
    <span class="dir-label-wrap">
      <span class="dir-label dir-label--off">Send</span>
      <span class="dir-label dir-label--on">Send</span>
    </span>
  </button>
  <p class="cf-status" id="status" role="status" aria-live="polite"></p>
</form>

<script>
const submit = document.getElementById("submit");
const fill = submit.querySelector(".dir-fill");
const ORIGINS = {left:"0% 50%", right:"100% 50%", top:"50% 0%", bottom:"50% 100%"};
const AXIS = {left:"scaleX", right:"scaleX", top:"scaleY", bottom:"scaleY"};
const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;

function edge(e, el){
  const r = el.getBoundingClientRect();
  const d = {left:(e.clientX-r.left)/r.width, right:(r.right-e.clientX)/r.width,
             top:(e.clientY-r.top)/r.height, bottom:(r.bottom-e.clientY)/r.height};
  let k="left", m=2;
  for (const key in d) if (d[key] < m){ m=d[key]; k=key; }
  return k;
}
function enter(e){
  const dur = reduce() ? 0 : 0.4;
  if (e){
    const ed = edge(e, submit), ax = AXIS[ed], other = ax==="scaleX"?"scaleY":"scaleX";
    gsap.set(fill, {transformOrigin:ORIGINS[ed], [other]:1, [ax]:0});
    gsap.to(fill, {[ax]:1, duration:dur, ease:"power3.out"});
  } else {
    gsap.set(fill, {transformOrigin:"50% 50%"});
    gsap.to(fill, {scaleX:1, scaleY:1, duration:dur, ease:"power2.out"});
  }
  const ld = reduce() ? 0 : 0.2;
  gsap.to(submit.querySelector(".dir-label--off"), {opacity:0, duration:ld});
  gsap.to(submit.querySelector(".dir-label--on"), {opacity:1, duration:ld, delay:reduce()?0:0.12});
}
function leave(e){
  const dur = reduce() ? 0 : 0.3;
  if (e){
    const ed = edge(e, submit), ax = AXIS[ed];
    gsap.set(fill, {transformOrigin:ORIGINS[ed]});
    gsap.to(fill, {[ax]:0, duration:dur, ease:"power3.in"});
  } else {
    gsap.to(fill, {scaleX:0, scaleY:0, duration:dur, ease:"power3.in"});
  }
  const ld = reduce() ? 0 : 0.15;
  gsap.to(submit.querySelector(".dir-label--on"), {opacity:0, duration:ld});
  gsap.to(submit.querySelector(".dir-label--off"), {opacity:1, duration:ld, delay:reduce()?0:0.1});
}

gsap.set(fill, {scaleX:0, scaleY:0, opacity:1, transformOrigin:"50% 50%"});
submit.addEventListener("mouseenter", e => enter(e));
submit.addEventListener("mouseleave", e => leave(e));
submit.addEventListener("focusin",  () => enter(null));
submit.addEventListener("focusout", () => leave(null));

const form = document.getElementById("cf");
const status = document.getElementById("status");
form.addEventListener("submit", (e) => {
  e.preventDefault();
  if (!form.checkValidity()){ form.reportValidity(); return; }
  // ponytail: no backend in a component demo, status fakes success
  status.textContent = "Sent. I will reply within two days.";
  status.classList.add("is-ok");
  form.reset();
});
</script>