Sand
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldSand(el) on the mounted element instead.
<div class="field-sand">
<span class="field-sand__wet" aria-hidden="true"></span>
<span class="field-sand__line" aria-hidden="true">
<span class="field-sand__ch">S</span>
<span class="field-sand__ch">e</span>
<span class="field-sand__ch">e</span>
<span class="field-sand__ch"> </span>
<span class="field-sand__ch">y</span>
<span class="field-sand__ch">o</span>
<span class="field-sand__ch">u</span>
<span class="field-sand__ch"> </span>
<span class="field-sand__ch">s</span>
<span class="field-sand__ch">o</span>
<span class="field-sand__ch">o</span>
<span class="field-sand__ch">n</span>
<i class="field-sand__stick"></i>
<i class="field-sand__twig field-sand__twig--cut">
<svg viewBox="0 0 34 34">
<path class="field-sand__wood" d="M1 33L31 3"></path>
<path class="field-sand__bark" d="M10 24L14 23M19 15L22 16M24 10L28 11"></path>
</svg>
</i>
</span>
<span class="field-sand__count" aria-hidden="true">12/16</span>
<input id="field-sand" class="field-sand__input" maxLength="16" autoComplete="off" aria-label="Message in the sand" value="See you soon"/>
<svg class="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"></path>
</svg>
<button type="button" class="field-sand__wash">
<svg viewBox="0 0 14 10" aria-hidden="true">
<path d="M1 4Q4 1 7 4T13 4M1 8Q4 5 7 8T13 8"></path>
</svg>
Wash away
</button>
<span class="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"></path>
</svg>
</span>
</div>.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; }
}function initFieldSand(root) {
var MAX = 16;
var line = root.querySelector('.field-sand__line');
var stick = root.querySelector('.field-sand__stick');
var count = root.querySelector('.field-sand__count');
var input = root.querySelector('.field-sand__input');
var wash = root.querySelector('.field-sand__wash');
var twig = line.querySelector('.field-sand__twig');
var chars = [].slice.call(line.querySelectorAll('.field-sand__ch')).map(function (el) {
return { ch: el.textContent || ' ', el: el };
});
var empty = document.createElement('span');
empty.className = 'field-sand__empty';
empty.setAttribute('aria-hidden', 'true');
empty.textContent = 'Tap to write in the sand';
var drawn = input.value.length;
var washing = false;
var timers = [];
function render() {
var text = input.value;
// A letter is traced again only when it changes.
for (var i = 0; i < Math.max(text.length, chars.length); i++) {
if (i >= text.length) {
chars[i].el.remove();
continue;
}
if (chars[i] && chars[i].ch === text[i]) continue;
var el = document.createElement('span');
el.className = 'field-sand__ch';
el.textContent = text[i];
if (chars[i]) chars[i].el.replaceWith(el);
else line.insertBefore(el, stick);
chars[i] = { ch: text[i], el: el };
}
chars.length = text.length;
// The stick scratches once per letter added or rubbed out.
if (text.length !== drawn) {
drawn = text.length;
var fresh = twig.cloneNode(true);
fresh.className = 'field-sand__twig' + (text.length ? ' field-sand__twig--cut' : '');
twig.replaceWith(fresh);
twig = fresh;
}
if (text) empty.remove();
else line.after(empty);
count.textContent = text.length + '/' + MAX;
}
// The wave reaches the writing halfway through; that is when the text goes.
function washAway() {
if (washing) return;
washing = true;
root.setAttribute('data-wash', '1');
timers.push(
setTimeout(function () {
input.value = '';
render();
}, 760),
setTimeout(function () {
root.removeAttribute('data-wash');
washing = false;
}, 1650),
);
input.focus();
}
function onKey(e) {
if (e.key === 'Escape') washAway();
}
input.addEventListener('input', render);
input.addEventListener('keydown', onKey);
wash.addEventListener('click', washAway);
return function () {
timers.forEach(clearTimeout);
input.removeEventListener('input', render);
input.removeEventListener('keydown', onKey);
wash.removeEventListener('click', washAway);
};
}
document.querySelectorAll('.field-sand').forEach(initFieldSand);Goal: Create a "Sand" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the sand; the grooves are drawn from its value. Wash away (or Escape) runs the wave and clears the field when the water reaches the writing.
- Letters are a script face in the groove colour with a lit lower lip and a dark upper edge, so they read as pressed into the sand; each traces in with a clip-path wipe.
- The stick rests where the next letter goes and scratches once per letter; with the field focused it lifts, ready to write.
- The sea laps at the top on a slow loop with a drifting foam edge. A wash surges it down the beach, darkens the wet sand behind it and blurs the writing away.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Sand" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the sand; the grooves are drawn from its value. Wash away (or Escape) runs the wave and clears the field when the water reaches the writing.
- Letters are a script face in the groove colour with a lit lower lip and a dark upper edge, so they read as pressed into the sand; each traces in with a clip-path wipe.
- The stick rests where the next letter goes and scratches once per letter; with the field focused it lifts, ready to write.
- The sea laps at the top on a slow loop with a drifting foam edge. A wash surges it down the beach, darkens the wet sand behind it and blurs the writing away.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.