Message in a bottle

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

const css = `
  /* One 2.4s send: the note rolls away, drops into the neck, the cork goes in, and off it floats. */
  /* The sheet rolls up from the top into a scroll, which carries the note down into the neck. */
  @keyframes field-bottle-roll { 0% { clip-path: inset(0 0 0 0); } 18%, 100% { clip-path: inset(100% 0 0 0); } }
  @keyframes field-bottle-carry {
    0% { transform: translate(0, -4px) scaleX(1); opacity: 1; }
    18% { transform: translate(0, 82px) scaleX(1); opacity: 1; }
    30% { transform: translate(-32px, 109px) scaleX(0.09); opacity: 1; }
    32%, 100% { transform: translate(-32px, 109px) scaleX(0.09); opacity: 0; }
  }
  @keyframes field-bottle-unroll { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
  @keyframes field-bottle-scroll { 0%, 29% { transform: translateX(30px); opacity: 0; } 31% { transform: translateX(30px); opacity: 1; } 40%, 100% { transform: translateX(0); opacity: 1; } }
  @keyframes field-bottle-cork { 0%, 40% { transform: translateX(12px); opacity: 0; } 42% { opacity: 1; } 47%, 100% { transform: translateX(0); opacity: 1; } }
  @keyframes field-bottle-tilt { 0%, 48% { transform: rotate(-32deg); } 62%, 100% { transform: rotate(-6deg); } }
  @keyframes field-bottle-sail { 0%, 50% { transform: translateX(0); opacity: 1; } 92% { opacity: 1; } 100% { transform: translateX(200px); opacity: 0; } }
  @keyframes field-bottle-arrive { from { transform: translateX(-90px); opacity: 0; } 30% { opacity: 1; } to { transform: translateX(0); } }
  @keyframes field-bottle-bob { 0%, 100% { transform: translateY(0) rotate(-3deg); } 50% { transform: translateY(2.5px) rotate(3deg); } }
  @keyframes field-bottle-waves { to { transform: translateX(-40px); } }
  @keyframes field-bottle-nudge { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 50% { transform: translateX(4px); } 75% { transform: translateX(-2px); } }
  .field-bottle { width: 300px; margin: 0; }
  .field-bottle__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
  .field-bottle__paper { position: relative; }
  /* The rolled-up sheet: a pale cylinder, shaded top and bottom. */
  .field-bottle__roll { position: absolute; left: 0; top: 0; width: 100%; height: 10px; border-radius: 5px; background: linear-gradient(to bottom, light-dark(#cbced3, #a7abb2), light-dark(#fbfcfd, #e5e6e9) 45%, light-dark(#dbdde1, #a7abb2)); opacity: 0; pointer-events: none; }
  .field-bottle[data-phase='send'] .field-bottle__note { animation: field-bottle-roll 2.4s ease-in forwards; }
  .field-bottle[data-phase='send'] .field-bottle__roll { animation: field-bottle-carry 2.4s ease-in-out forwards; }
  .field-bottle[data-phase='send'] .field-bottle__send { opacity: 0; }
  .field-bottle[data-phase='sent'] .field-bottle__note { animation: field-bottle-unroll 0.45s ease-out; }
  .field-bottle[data-phase='empty'] .field-bottle__paper { animation: field-bottle-nudge 0.35s ease-out; }
  /* Ruled paper, pale in both themes. */
  .field-bottle__note {
    display: block;
    width: 100%;
    height: 90px;
    box-sizing: border-box;
    margin: 0;
    padding: 9px 12px 9px;
    border: 0;
    border-radius: 6px 6px 3px 3px;
    background: repeating-linear-gradient(to bottom, transparent 0 21px, light-dark(#dbdde1, #a7abb2) 21px 22px) local, light-dark(#fbfcfd, #e5e6e9);
    background-position: 0 9px, 0 0;
    box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25));
    color: light-dark(#373a40, #232529);
    font: 15px/22px system-ui, sans-serif;
    resize: none;
    outline: none;
  }
  .field-bottle__note::placeholder { color: light-dark(#6d7179, #7d8087); }
  .field-bottle__paper:focus-within { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; border-radius: 6px; }
  .field-bottle__send { position: absolute; right: 8px; bottom: 8px; padding: 6px 14px; border: 0; border-radius: 999px; background: light-dark(#42454c, #232529); color: light-dark(#fbfcfd, #f1f2f3); font: 600 13px system-ui, sans-serif; cursor: pointer; }
  .field-bottle__send:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-bottle__sea { display: block; width: 300px; height: 64px; margin-top: 6px; overflow: hidden; border-radius: 0 0 10px 10px; }
  .field-bottle__waves { animation: field-bottle-waves 3s linear infinite; }
  .field-bottle__waves--front { animation-duration: 2.2s; }
  .field-bottle__back { fill: light-dark(#ebedf0, #232529); }
  .field-bottle__front { fill: light-dark(#dbdde1, #2e3035); }
  .field-bottle__crest { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; }
  .field-bottle__bob { animation: field-bottle-bob 2.6s ease-in-out infinite; }
  .field-bottle__tilt { transform: rotate(-32deg); transform-origin: 17px 0; }
  .field-bottle__glass { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 22%, transparent); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.4; stroke-linejoin: round; }
  .field-bottle__shine { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.2; stroke-linecap: round; opacity: 0.6; }
  .field-bottle__cork { fill: light-dark(#6d7179, #7d8087); opacity: 0; }
  .field-bottle__scroll { fill: light-dark(#ebedf0, #e5e6e9); stroke: light-dark(#cbced3, #a7abb2); stroke-width: 0.8; opacity: 0; }
  .field-bottle[data-phase='send'] .field-bottle__tilt { animation: field-bottle-tilt 2.4s ease-in-out forwards; }
  .field-bottle[data-phase='send'] .field-bottle__ride { animation: field-bottle-sail 2.4s cubic-bezier(0.5, 0, 0.8, 0.6) forwards; }
  .field-bottle[data-phase='send'] .field-bottle__scroll { animation: field-bottle-scroll 2.4s ease-in-out forwards; }
  .field-bottle[data-phase='send'] .field-bottle__cork { animation: field-bottle-cork 2.4s ease-out forwards; }
  .field-bottle[data-phase='sent'] .field-bottle__ride { animation: field-bottle-arrive 1.4s cubic-bezier(0.2, 0.6, 0.3, 1); }
  .field-bottle__status { min-height: 18px; margin: 8px 0 0; color: light-dark(#5f626a, #a7abb2); font-size: 13px; }
  @media (prefers-reduced-motion: reduce) {
    .field-bottle__waves, .field-bottle__bob, .field-bottle[data-phase] .field-bottle__note, .field-bottle[data-phase] .field-bottle__roll, .field-bottle[data-phase] .field-bottle__ride,
    .field-bottle[data-phase] .field-bottle__tilt, .field-bottle[data-phase] .field-bottle__scroll, .field-bottle[data-phase] .field-bottle__cork { animation: none; }
  }
`;

const WAVE = (y: number) => `M0 ${y}${Array.from({ length: 9 }, () => 'q10 -5 20 0t20 0').join('')}V64H0Z`;

export function BottleInput() {
  const id = useId();
  const [v, setV] = useState('');
  const [phase, setPhase] = useState<'idle' | 'send' | 'sent' | 'empty'>('idle');
  const [n, setN] = useState(0);
  const msg = { idle: '', send: 'Sending…', sent: "Sent. It's on its way.", empty: 'Write a few words first.' }[phase];
  const finish = () => {
    setV('');
    setN((k) => k + 1);
    setPhase('sent');
  };
  return (
    <>
      <style>
        {css}
      </style>
      <form
        className="field-bottle"
        data-phase={phase}
        onSubmit={(e) => {
          e.preventDefault();
          if (phase === 'send') return;
          if (!v.trim()) return setPhase('empty');
          if (matchMedia('(prefers-reduced-motion: reduce)').matches) return finish();
          setPhase('send');
        }}
        onAnimationEnd={(e) => {
          if (e.animationName === 'field-bottle-sail') finish();
          if (e.animationName === 'field-bottle-nudge') setPhase('idle');
        }}
      >
        <label className="field-bottle__label" htmlFor={id}>
          Message
        </label>
        <div className="field-bottle__paper">
          <textarea
            id={id}
            className="field-bottle__note"
            value={v}
            placeholder="Tell us what you're making"
            readOnly={phase === 'send'}
            aria-describedby={`${id}-status`}
            onChange={(e) => {
              setV(e.target.value);
              if (phase !== 'send') setPhase('idle');
            }}
          />
          <button className="field-bottle__send" type="submit">
            Send
          </button>
          <span className="field-bottle__roll" aria-hidden="true" />
        </div>
        <svg className="field-bottle__sea" viewBox="0 0 300 64" aria-hidden="true">
          <g className="field-bottle__waves">
            <path className="field-bottle__back" d={WAVE(26)} />
          </g>
          <g transform="translate(70 34) scale(1.3)">
            <g key={n} className="field-bottle__ride">
              <g className="field-bottle__bob">
                <g className="field-bottle__tilt">
                  <rect className="field-bottle__scroll" x="1" y="-3.2" width="24" height="6.4" rx="3.2" />
                  <rect className="field-bottle__cork" x="39" y="-3.4" width="7" height="6.8" rx="1.5" />
                  <path className="field-bottle__glass" d="M0 -9H23Q29 -9 31 -4H40V4H31Q29 9 23 9H0Q-6 9 -6 0Q-6 -9 0 -9Z" />
                  <path className="field-bottle__shine" d="M2 -5.5H18" />
                </g>
              </g>
            </g>
          </g>
          <g className="field-bottle__waves field-bottle__waves--front">
            <path className="field-bottle__front" d={WAVE(40)} />
            <path className="field-bottle__crest" d={`M0 40${Array.from({ length: 9 }, () => 'q10 -5 20 0t20 0').join('')}`} />
          </g>
        </svg>
        <p className="field-bottle__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-bottle
Source on GitHub

About this component

A message field on a sheet of paper: sending rolls the note up into a bottle, the cork goes in and it floats off over the waves while a fresh bottle drifts in.

Dependencies
None
Difficulty
Advanced
Triggers
state change, click, loop
Techniques
keyframes, clip path, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A form with a labelled textarea and a Send button; the result is read out from a role="status" line, and the sea is aria-hidden. Under prefers-reduced-motion it sends at once.

More inputs

See all

Try SAVE20

Coupon
Label maker
Letter board

Mailbox