Skywriter

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

const css = `
  /* Each letter puffs out of the smoke: small and soft, then full size and sharp. */
  @keyframes field-skywrite-puff { 0% { transform: scale(0.3); filter: blur(4px); opacity: 0; } 45% { transform: scale(1.18); filter: blur(1.5px); opacity: 1; } 100% { transform: none; filter: blur(0); } }
  @keyframes field-skywrite-bob { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-3px) rotate(2deg); } }
  @keyframes field-skywrite-cloud { from { transform: translateX(0); } to { transform: translateX(-340px); } }
  @keyframes field-skywrite-smoke { 0% { transform: translateX(0) scale(0.5); opacity: 0.7; } 100% { transform: translateX(-22px) scale(1.6); opacity: 0; } }
  .field-skywrite { width: 300px; }
  .field-skywrite__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
  .field-skywrite__sky { position: relative; height: 112px; overflow: hidden; border-radius: 14px; background: light-dark(#f3f4f7, #1a1c1f); }
  .field-skywrite__sky:focus-within { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
  .field-skywrite__cloud { position: absolute; width: 60px; height: 18px; border-radius: 999px; background: light-dark(#ebedf0, #232529); animation: field-skywrite-cloud 40s linear infinite; }
  .field-skywrite__cloud::before { content: ''; position: absolute; left: 14px; top: -10px; width: 26px; height: 22px; border-radius: 50%; background: inherit; }
  .field-skywrite__input, .field-skywrite__mirror {
    box-sizing: border-box;
    padding: 0 16px;
    font: 700 26px/44px ui-rounded, 'SF Pro Rounded', system-ui, sans-serif;
    letter-spacing: 1px;
    font-kerning: none;
    font-variant-ligatures: none;
  }
  .field-skywrite__input { position: absolute; left: 0; top: 36px; width: 100%; height: 44px; margin: 0; border: 0; outline: none; background: transparent; color: transparent; caret-color: light-dark(#5f626a, #a7abb2); }
  /* Empty, the hint starts just past the waiting plane. */
  .field-skywrite__input:placeholder-shown { padding-left: 60px; }
  .field-skywrite__input::placeholder { color: light-dark(#6d7179, #7d8087); font-size: 18px; font-weight: 500; }
  .field-skywrite__input::selection { background: light-dark(#cbced3, #393c41); }
  .field-skywrite__mirror { position: absolute; left: 0; top: 36px; height: 44px; color: light-dark(#42454c, #e5e6e9); white-space: pre; pointer-events: none; text-shadow: 0 0 8px color-mix(in srgb, light-dark(#42454c, #e5e6e9) 45%, transparent); }
  .field-skywrite__ch { display: inline-block; }
  .field-skywrite__ch--new { animation: field-skywrite-puff 0.45s ease-out; }
  /* The plane is the caret: it flies to just past the last letter. */
  .field-skywrite__plane { position: absolute; left: 0; top: 40px; width: 38px; height: 22px; transition: transform 0.35s cubic-bezier(0.3, 0.7, 0.4, 1); }
  .field-skywrite__plane svg { display: block; width: 38px; height: 22px; overflow: visible; animation: field-skywrite-bob 1.6s ease-in-out infinite; }
  .field-skywrite__body { fill: light-dark(#50535b, #c6c8cd); }
  .field-skywrite__wing { fill: light-dark(#5f626a, #a7abb2); }
  .field-skywrite__window { fill: light-dark(#ebedf0, #232529); }
  .field-skywrite__smoke { fill: light-dark(#5f626a, #a7abb2); transform-box: fill-box; transform-origin: center; animation: field-skywrite-smoke 0.9s ease-out infinite; }
  .field-skywrite__smoke + .field-skywrite__smoke { animation-delay: -0.45s; }
  .field-skywrite__sky:not(:focus-within) .field-skywrite__smoke { animation-play-state: paused; opacity: 0; }
  @media (prefers-reduced-motion: reduce) {
    .field-skywrite__plane { transition: none; }
    .field-skywrite__plane svg, .field-skywrite__cloud, .field-skywrite__ch--new, .field-skywrite__smoke { animation: none; }
  }
`;

export function SkywriteInput() {
  const id = useId();
  const [v, setV] = useState('');
  const [grew, setGrew] = useState(false);
  const [x, setX] = useState(16);
  const line = useRef<HTMLSpanElement>(null);
  // Fly to the end of the text, but never off the edge of the sky.
  useLayoutEffect(() => setX(Math.min(16 + (line.current?.offsetWidth ?? 0) + 4, 256)), [v]);
  return (
    <>
      <style>
        {css}
      </style>
      <div className="field-skywrite">
        <label className="field-skywrite__label" htmlFor={id}>
          Sky message
        </label>
        <div className="field-skywrite__sky">
          <span className="field-skywrite__cloud" style={{ left: '210px', top: '14px' }} aria-hidden="true" />
          <span className="field-skywrite__cloud" style={{ left: '360px', top: '84px', animationDelay: '-18s' }} aria-hidden="true" />
          <span className="field-skywrite__mirror" aria-hidden="true">
            <span ref={line}>
              {[...v].map((ch, i) => (
                <span key={`${i}${ch}`} className={`field-skywrite__ch${grew && i === v.length - 1 ? ' field-skywrite__ch--new' : ''}`}>
                  {ch}
                </span>
              ))}
            </span>
          </span>
          <input
            id={id}
            className="field-skywrite__input"
            value={v}
            maxLength={14}
            placeholder="Write in the sky"
            autoComplete="off"
            spellCheck={false}
            onChange={(e) => {
              setGrew(e.target.value.length > v.length);
              setV(e.target.value);
            }}
          />
          <span className="field-skywrite__plane" style={{ transform: `translateX(${x}px)` }} aria-hidden="true">
            <svg viewBox="0 0 38 22">
              <circle className="field-skywrite__smoke" cx="2" cy="12" r="3" />
              <circle className="field-skywrite__smoke" cx="2" cy="12" r="3" />
              <path className="field-skywrite__wing" d="M14 12L22 21H26L22 12Z" />
              <path className="field-skywrite__body" d="M6 9L8 4H11L14 9H30Q36 9 36 12Q36 15 30 15H10Q6 15 6 12Z" />
              <path className="field-skywrite__wing" d="M17 10L22 2H25.5L23 10Z" />
              <path className="field-skywrite__window" d="M28 10.5H32Q33.5 10.5 33.8 12H28Z" />
            </svg>
          </span>
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A small plane writes your text across the sky: it flies just ahead of the caret and each letter puffs out of its smoke trail.

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

A real input with a visible <label>; the mirror, plane and clouds are aria-hidden. Under prefers-reduced-motion the plane jumps and letters appear without the puff.

More inputs

See all
Card number
Type a 24-hour time
Dial time
0 of 5 syllables
0 of 7 syllables
0 of 5 syllables
Haiku
Match