Message in a bottle
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldBottle(el) on the mounted element instead.
<form class="field-bottle" data-phase="idle">
<label class="field-bottle__label" for="field-bottle">Message</label>
<div class="field-bottle__paper">
<textarea id="field-bottle" class="field-bottle__note" placeholder="Tell us what you're making" aria-describedby="field-bottle-status"></textarea>
<button class="field-bottle__send" type="submit">Send</button>
<span class="field-bottle__roll" aria-hidden="true"></span>
</div>
<svg class="field-bottle__sea" viewBox="0 0 300 64" aria-hidden="true">
<g class="field-bottle__waves">
<path class="field-bottle__back" d="M0 26q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0V64H0Z"></path>
</g>
<g transform="translate(70 34) scale(1.3)">
<g class="field-bottle__ride">
<g class="field-bottle__bob">
<g class="field-bottle__tilt">
<rect class="field-bottle__scroll" x="1" y="-3.2" width="24" height="6.4" rx="3.2"></rect>
<rect class="field-bottle__cork" x="39" y="-3.4" width="7" height="6.8" rx="1.5"></rect>
<path class="field-bottle__glass" d="M0 -9H23Q29 -9 31 -4H40V4H31Q29 9 23 9H0Q-6 9 -6 0Q-6 -9 0 -9Z"></path>
<path class="field-bottle__shine" d="M2 -5.5H18"></path>
</g>
</g>
</g>
</g>
<g class="field-bottle__waves field-bottle__waves--front">
<path class="field-bottle__front" d="M0 40q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0V64H0Z"></path>
<path class="field-bottle__crest" d="M0 40q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0q10 -5 20 0t20 0"></path>
</g>
</svg>
<p class="field-bottle__status" id="field-bottle-status" role="status"></p>
</form>/* 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; }
}function initFieldBottle(root) {
var MSG = { idle: '', send: 'Sending…', sent: "Sent. It's on its way.", empty: 'Write a few words first.' };
var note = root.querySelector('.field-bottle__note');
var status = root.querySelector('.field-bottle__status');
var phase = 'idle';
function set(next) {
phase = next;
root.setAttribute('data-phase', next);
note.readOnly = next === 'send';
status.textContent = MSG[next];
}
// The bottle has sailed: clear the note and float a fresh bottle in (a new element, so it drifts in from the start).
function finish() {
note.value = '';
var ride = root.querySelector('.field-bottle__ride');
ride.replaceWith(ride.cloneNode(true));
set('sent');
}
function onSubmit(e) {
e.preventDefault();
if (phase === 'send') return;
if (!note.value.trim()) return set('empty');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return finish();
set('send');
}
function onInput() {
if (phase !== 'send') set('idle');
}
function onEnd(e) {
if (e.animationName === 'field-bottle-sail') finish();
if (e.animationName === 'field-bottle-nudge') set('idle');
}
root.addEventListener('submit', onSubmit);
note.addEventListener('input', onInput);
root.addEventListener('animationend', onEnd);
return function () {
root.removeEventListener('submit', onSubmit);
note.removeEventListener('input', onInput);
root.removeEventListener('animationend', onEnd);
};
}
document.querySelectorAll('.field-bottle').forEach(initFieldBottle);Goal: Create a "Message in a bottle" input.
Visual behavior: 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.
Technique:
- One 2.4s send on a data attribute drives every part: the sheet is clipped away from the top while a paper roll runs down it and carries the note to the bottle's neck, the scroll slides inside, the cork follows, and the bottle levels out and sails off.
- When the sail ends the note is cleared and a fresh bottle drifts in from the left as the sheet unrolls. The waves are two strips sliding at different speeds.
Accessibility: 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.
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 "Message in a bottle" input.
Visual behavior: 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.
Technique:
- One 2.4s send on a data attribute drives every part: the sheet is clipped away from the top while a paper roll runs down it and carries the note to the bottle's neck, the scroll slides inside, the cork follows, and the bottle levels out and sails off.
- When the sail ends the note is cleared and a fresh bottle drifts in from the left as the sheet unrolls. The waves are two strips sliding at different speeds.
Accessibility: 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.
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 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.