Mailbox

No dependencies
Code format
React + Tailwind
import { useId, useState } from 'react';

const css = `
  @keyframes field-mailbox-fly {
    0% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; }
    50% { transform: translate(36px, -40px) scale(0.75) rotate(-12deg); opacity: 1; }
    85% { transform: translate(58px, -24px) scale(0.42) rotate(0deg); opacity: 1; }
    100% { transform: translate(59px, -20px) scale(0.36); opacity: 0; }
  }
  @keyframes field-mailbox-rattle { 0%, 100% { transform: rotate(0deg); } 20% { transform: rotate(-5deg); } 40% { transform: rotate(4deg); } 60% { transform: rotate(-3deg); } 80% { transform: rotate(1.5deg); } }
  .field-mailbox { width: 300px; margin: 0; }
  .field-mailbox__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
  .field-mailbox__row { position: relative; display: flex; align-items: flex-end; gap: 10px; height: 74px; }
  .field-mailbox__input {
    flex: 1;
    min-width: 0;
    height: 44px;
    box-sizing: border-box;
    padding: 0 14px;
    border: 1px solid light-dark(#dbdde1, #2e3035);
    border-radius: 10px;
    background: light-dark(#f3f4f7, #1a1c1f);
    color: light-dark(#373a40, #f1f2f3);
    font: 15px system-ui, sans-serif;
    outline: none;
    transition: border-color 0.2s;
  }
  .field-mailbox__input::placeholder { color: light-dark(#6d7179, #7d8087); }
  .field-mailbox__input:focus-visible { border-color: light-dark(#5f626a, #a7abb2); }
  .field-mailbox__send { flex: none; width: 72px; height: 74px; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; }
  .field-mailbox__send:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-mailbox__send svg { display: block; width: 72px; height: 74px; overflow: visible; }
  .field-mailbox__box { transform-box: view-box; transform-origin: 32px 72px; }
  .field-mailbox[data-state='error'] .field-mailbox__box { animation: field-mailbox-rattle 0.45s ease-out; }
  .field-mailbox__post { fill: light-dark(#cbced3, #393c41); }
  .field-mailbox__shell { fill: light-dark(#6d7179, #7d8087); }
  .field-mailbox__inside { fill: light-dark(#373a40, #121417); }
  /* The door folds down toward you on its bottom hinge. */
  .field-mailbox__door { fill: light-dark(#5f626a, #a7abb2); transform-box: view-box; transform-origin: 32px 43px; transition: transform 0.35s cubic-bezier(0.3, 1.4, 0.5, 1); }
  .field-mailbox__handle { fill: light-dark(#42454c, #e5e6e9); }
  .field-mailbox[data-open] .field-mailbox__door, .field-mailbox[data-open] .field-mailbox__handle { transform: scaleY(-0.18); }
  .field-mailbox__handle { transform-box: view-box; transform-origin: 32px 43px; transition: transform 0.35s cubic-bezier(0.3, 1.4, 0.5, 1); }
  .field-mailbox__flag { fill: light-dark(#42454c, #e5e6e9); transform-box: view-box; transform-origin: 55px 36px; transform: rotate(90deg); transition: transform 0.5s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .field-mailbox[data-state='sent'] .field-mailbox__flag { transform: rotate(0deg); transition-delay: 0.15s; }
  .field-mailbox__letter { position: absolute; left: 190px; top: 40px; width: 22px; height: 15px; border-radius: 2px; background: light-dark(#ebedf0, #e5e6e9); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); opacity: 0; pointer-events: none; }
  .field-mailbox__letter::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom right, transparent 48%, light-dark(#cbced3, #7d8087) 49%, transparent 54%), linear-gradient(to bottom left, transparent 48%, light-dark(#cbced3, #7d8087) 49%, transparent 54%); background-size: 100% 70%; background-repeat: no-repeat; }
  .field-mailbox[data-state='send'] .field-mailbox__letter { animation: field-mailbox-fly 0.7s cubic-bezier(0.4, 0, 0.5, 1) forwards; }
  .field-mailbox__status { min-height: 18px; margin: 10px 0 0; color: light-dark(#5f626a, #a7abb2); font-size: 13px; }
  @media (prefers-reduced-motion: reduce) {
    .field-mailbox__door, .field-mailbox__handle, .field-mailbox__flag { transition: none; }
    .field-mailbox[data-state] .field-mailbox__box, .field-mailbox[data-state] .field-mailbox__letter { animation: none; }
  }
`;

const VALID = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const ARCH = (i: number) => `M${10 + i} 43V${22 + i * 0.3}Q${10 + i} ${6 + i} 32 ${6 + i}Q${54 - i} ${6 + i} ${54 - i} ${22 + i * 0.3}V43Z`;

export function MailboxInput() {
  const id = useId();
  const [v, setV] = useState('');
  const [state, setState] = useState<'idle' | 'send' | 'sent' | 'error'>('idle');
  const valid = VALID.test(v.trim());
  const msg = { idle: '', send: 'Sending…', sent: "You're on the list. Check your inbox.", error: 'Add a full address, like you@studio.com' }[state];
  return (
    <>
      <style>
        {css}
      </style>
      <form
        className="field-mailbox"
        data-state={state}
        data-open={(valid && state !== 'sent') || undefined}
        onSubmit={(e) => {
          e.preventDefault();
          if (state === 'send' || state === 'sent') return;
          if (!valid) return setState('error');
          setState(matchMedia('(prefers-reduced-motion: reduce)').matches ? 'sent' : 'send');
        }}
        onAnimationEnd={(e) => {
          if (e.animationName === 'field-mailbox-fly') setState('sent');
          if (e.animationName === 'field-mailbox-rattle') setState('idle');
        }}
      >
        <label className="field-mailbox__label" htmlFor={id}>
          Email
        </label>
        <div className="field-mailbox__row">
          <input
            id={id}
            className="field-mailbox__input"
            type="email"
            value={v}
            placeholder="you@studio.com"
            autoComplete="email"
            aria-describedby={`${id}-status`}
            onChange={(e) => {
              setV(e.target.value);
              if (state !== 'send') setState('idle');
            }}
          />
          <button className="field-mailbox__send" type="submit" aria-label="Subscribe">
            <svg viewBox="0 0 72 74" aria-hidden="true">
              <g className="field-mailbox__box">
                <rect className="field-mailbox__post" x="28" y="42" width="8" height="32" rx="1.5" />
                <path className="field-mailbox__shell" d={ARCH(0)} />
                <path className="field-mailbox__inside" d={ARCH(4)} />
                <path className="field-mailbox__door" d={ARCH(4)} />
                <rect className="field-mailbox__handle" x="28" y="14" width="8" height="3" rx="1.5" />
                <path className="field-mailbox__flag" d="M54 37V20H62V26H57V37Z" />
              </g>
            </svg>
          </button>
          <span className="field-mailbox__letter" aria-hidden="true" />
        </div>
        <p className="field-mailbox__status" id={`${id}-status`} role="status">
          {msg}
        </p>
      </form>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-mailbox
Source on GitHub

About this component

An email signup with a mailbox: the door drops open once the address is valid, sending flies the letter in, the door shuts and the flag goes up.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, click, spring
Techniques
keyframes, css transition, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A form with a labelled email input; the mailbox is the submit button, named "Subscribe", and the result is read out from a role="status" line. Under prefers-reduced-motion it sends at once.

More inputs

See all
Name tag
Piggy bank
Price tag

Up to 5 kg

Kitchen scale