Sand

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

const MAX = 16;

const css = `
  .field-sand { --sand: light-dark(light-dark(#dbdde1, #2e3035), color-mix(in oklab, light-dark(#6d7179, #7d8087) 62%, light-dark(#cbced3, #393c41))); --groove: light-dark(#5f626a, #1a1c1f); --lip: light-dark(#fbfcfd, #c6c8cd); --sea: light-dark(#6d7179, #232529); --foam: light-dark(#fbfcfd, #c6c8cd); --stick: light-dark(#50535b, #e5e6e9); --label: light-dark(#50535b, #e5e6e9); position: relative; width: 262px; height: 168px; overflow: hidden; border-radius: 14px; cursor: text; background: radial-gradient(circle, color-mix(in oklab, var(--groove) 30%, transparent) 0.6px, transparent 0.9px) 0 0 / 5px 5px, radial-gradient(circle, color-mix(in oklab, var(--lip) 30%, transparent) 0.6px, transparent 0.9px) 2px 3px / 7px 7px, linear-gradient(color-mix(in oklab, var(--sand) 82%, var(--groove)), var(--sand) 45%); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--groove) 40%, transparent); }
  .field-sand:has(.field-sand__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  /* The sea laps at the top; a wash sends it right up the beach and back. */
  .field-sand__sea { position: absolute; left: -10px; right: -10px; top: -150px; height: 182px; background: linear-gradient(var(--sea) 70%, color-mix(in oklab, var(--sea) 45%, transparent)); animation: field-sand-lap 4.5s ease-in-out infinite; z-index: 2; pointer-events: none; }
  .field-sand__sea svg { position: absolute; left: 0; bottom: -9px; width: 600px; height: 12px; animation: field-sand-drift 6s linear infinite; }
  .field-sand__sea path { fill: var(--foam); }
  @keyframes field-sand-lap { 50% { transform: translateY(7px); } }
  @keyframes field-sand-drift { to { transform: translateX(-200px); } }
  .field-sand[data-wash] .field-sand__sea { animation: field-sand-surge 1.6s cubic-bezier(0.45, 0, 0.35, 1); }
  @keyframes field-sand-surge { 0% { transform: none; } 45% { transform: translateY(150px); } 100% { transform: none; } }
  .field-sand__wet { position: absolute; inset: 0; background: linear-gradient(color-mix(in oklab, var(--groove) 40%, transparent), transparent 60%); opacity: 0; pointer-events: none; }
  .field-sand[data-wash] .field-sand__wet { animation: field-sand-wet 2.6s ease-out; }
  @keyframes field-sand-wet { 40% { opacity: 1; } }
  /* The writing: grooves pressed into the sand with a lit lip, drawn in letter by letter. */
  .field-sand__line { position: absolute; left: 10px; right: 10px; top: 72px; display: flex; justify-content: center; align-items: baseline; height: 44px; color: var(--groove); font: 600 32px/44px 'Snell Roundhand', 'Segoe Script', 'Brush Script MT', 'Apple Chancery', cursive; -webkit-text-stroke: 0.6px var(--groove); text-shadow: 0 1.2px 0 var(--lip), 0 -1px 0 color-mix(in oklab, var(--groove) 60%, light-dark(#fbfcfd, #121417)); white-space: pre; pointer-events: none; }
  .field-sand__ch { display: inline-block; animation: field-sand-trace 0.32s ease-out both; }
  @keyframes field-sand-trace { from { clip-path: inset(0 100% 0 0); filter: blur(1px); } to { clip-path: inset(0 -20% 0 0); } }
  .field-sand[data-wash] .field-sand__line { animation: field-sand-smooth 0.9s ease-in 0.3s forwards; }
  @keyframes field-sand-smooth { to { opacity: 0; filter: blur(4px); } }
  .field-sand__empty { position: absolute; left: 0; right: 0; top: 118px; color: var(--label); font: 500 12px/1 system-ui, sans-serif; text-align: center; pointer-events: none; }
  .field-sand__stick { width: 2px; height: 30px; margin-left: 3px; border-radius: 1px; background: var(--groove); align-self: center; opacity: 0; }
  /* The stick that wrote it rests where the next letter goes; it scratches once per letter. */
  .field-sand__twig { position: relative; width: 0; align-self: stretch; }
  .field-sand__twig svg { position: absolute; left: 1px; bottom: 6px; width: 34px; height: 34px; overflow: visible; transform-origin: 0 100%; filter: drop-shadow(3px 1px 1.5px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); transition: transform 0.3s ease-out; }
  .field-sand__twig--cut svg { animation: field-sand-scratch 0.3s ease-out; }
  @keyframes field-sand-scratch { 35% { transform: translate(-3px, 1px) rotate(-6deg); } }
  .field-sand:has(.field-sand__input:focus) .field-sand__twig svg { transform: translateY(-3px) rotate(-4deg); }
  .field-sand__twig path { fill: none; stroke-linecap: round; }
  .field-sand__twig .field-sand__wood { stroke: var(--stick); stroke-width: 3; }
  .field-sand__twig .field-sand__bark { stroke: color-mix(in oklab, var(--stick) 55%, var(--groove)); stroke-width: 1; }
  .field-sand__count { position: absolute; left: 14px; bottom: 13px; color: var(--label); font: 600 11px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
  .field-sand:has(.field-sand__input:focus) .field-sand__stick { animation: field-sand-blink 1s steps(1) infinite; }
  @keyframes field-sand-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
  .field-sand__input { position: absolute; left: 0; right: 0; top: 46px; bottom: 34px; z-index: 1; width: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: transparent; caret-color: transparent; font-size: 16px; outline: none; cursor: text; }
  .field-sand__input::selection { background: transparent; }
  .field-sand__shell { position: absolute; right: 16px; top: 44px; width: 18px; height: 14px; fill: none; stroke: var(--groove); stroke-width: 1.2; stroke-linecap: round; }
  .field-sand__wash { position: absolute; right: 10px; bottom: 9px; z-index: 3; display: flex; align-items: center; gap: 5px; height: 24px; padding: 0 9px; border: 0; border-radius: 12px; background: color-mix(in oklab, var(--groove) 30%, transparent); color: light-dark(#373a40, #f1f2f3); font: 600 11px system-ui, sans-serif; cursor: pointer; transition: background-color 0.2s; }
  .field-sand__wash:hover { background: color-mix(in oklab, var(--groove) 50%, transparent); }
  .field-sand__wash:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-sand__wash svg { width: 14px; height: 10px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
  @media (prefers-reduced-motion: reduce) {
    .field-sand * { animation: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const [text, setText] = useState('See you soon');
  const [wash, setWash] = useState(0);
  // The wave reaches the writing halfway through; that is when the text goes.
  const washAway = () => {
    if (wash) return;
    setWash(1);
    setTimeout(() => setText(''), 760);
    setTimeout(() => setWash(0), 1650);
    input.current?.focus();
  };
  return (
    <>
      <style href="kit-inputs-sand" precedence="kit">
        {css}
      </style>
      <div className="field-sand" data-wash={wash || undefined}>
        <span className="field-sand__wet" aria-hidden="true" />
        <span className="field-sand__line" aria-hidden="true">
          {[...text].map((ch, i) => (
            <span key={`${i}${ch}`} className="field-sand__ch">
              {ch}
            </span>
          ))}
          <i className="field-sand__stick" />
          <i key={text.length} className={`field-sand__twig${text.length ? ' field-sand__twig--cut' : ''}`}>
            <svg viewBox="0 0 34 34">
              <path className="field-sand__wood" d="M1 33L31 3" />
              <path className="field-sand__bark" d="M10 24L14 23M19 15L22 16M24 10L28 11" />
            </svg>
          </i>
        </span>
        {!text && (
          <span className="field-sand__empty" aria-hidden="true">
            Tap to write in the sand
          </span>
        )}
        <span className="field-sand__count" aria-hidden="true">
          {text.length}/{MAX}
        </span>
        <input
          ref={input}
          id={id}
          className="field-sand__input"
          maxLength={MAX}
          autoComplete="off"
          aria-label="Message in the sand"
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={(e) => e.key === 'Escape' && washAway()}
        />
        <svg className="field-sand__shell" viewBox="0 0 18 14" aria-hidden="true">
          <path d="M9 13C4 13 1 9 2 5C4 1 14 1 16 5C17 9 14 13 9 13ZM9 13L5 3.5M9 13L9 2.5M9 13L13 3.5" />
        </svg>
        <button type="button" className="field-sand__wash" onClick={washAway}>
          <svg viewBox="0 0 14 10" aria-hidden="true">
            <path d="M1 4Q4 1 7 4T13 4M1 8Q4 5 7 8T13 8" />
          </svg>
          Wash away
        </button>
        <span className="field-sand__sea" aria-hidden="true">
          <svg viewBox="0 0 600 12" preserveAspectRatio="none">
            <path d="M0 0V5Q25 12 50 5T100 5T150 5T200 5T250 5T300 5T350 5T400 5T450 5T500 5T550 5T600 5V0Z" />
          </svg>
        </span>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A message written in wet sand: the stick traces each letter into the beach, and Wash away sends a wave up the sand that smooths it out.

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

A native input named "Message in the sand", transparent over the beach so a click anywhere focuses it; focus outlines the sand. Wash away is a real button. The writing, the stick and the count are aria-hidden. Under prefers-reduced-motion the sea is still and the message simply clears.

More inputs

See all
Shredder
Sieve

Message in a bottle

Try SAVE20

Coupon