Website Sections
EnvelopeSignup
A newsletter signup where the address line of an open envelope is the email field. On send the letter drops in, the flap folds shut, a postmark lands with the date and the envelope goes through the slot of a red post box. A bad address comes back stamped.
Download
- React
- Tailwind CSS v4
- motion
components/ui/envelope-signup.tsx
"use client";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react";
type EnvelopeSignupProps = {
/** Printed on the letter. */
title?: string;
/** Small line on the letter. */
subtitle?: string;
/** Hint inside the address line. */
placeholder?: string;
/** Words on the button. */
buttonLabel?: string;
/** Field name, for a form post. */
name?: string;
/** Your signup call. A rejected promise sends the letter back. */
onSubmit?: (email: string) => void | Promise<void>;
/** Set the width here. Everything scales with it. */
className?: string;
};
type Phase = "open" | "sealing" | "stamped" | "posting" | "posted";
const wait = (ms: number) => new Promise((done) => setTimeout(done, ms));
/* EnvelopeSignup: a newsletter signup where the address line of an open envelope is the email field. On send the letter drops in, the flap folds shut, a postmark lands with the date, and the envelope goes through the slot of a red post box. A bad address comes back stamped. */
export function EnvelopeSignup({ title = "One good letter a month", subtitle = "New pieces and the story behind them. No spam, leave any time.", placeholder = "you@example.com", buttonLabel = "Send", name = "email", onSubmit, className = "w-full max-w-xl" }: EnvelopeSignupProps) {
const reduce = useReducedMotion();
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
const input = useRef<HTMLInputElement>(null);
const again = useRef<HTMLButtonElement>(null);
const shake = useAnimationControls();
const alive = useRef(true);
const [phase, setPhase] = useState<Phase>("open");
const [email, setEmail] = useState("");
const [sent, setSent] = useState("");
const [date, setDate] = useState("");
const [error, setError] = useState("");
const [returned, setReturned] = useState(0);
const [letters, setLetters] = useState(0);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
if (phase === "posted") again.current?.focus();
}, [phase]);
const refuse = (message: string) => {
setPhase("open");
setError(message);
setReturned((r) => r + 1);
if (!reduce) void shake.start({ x: [0, -9, 8, -6, 4, 0], transition: { duration: 0.42 } });
input.current?.focus();
};
const send = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (phase !== "open") return;
const field = input.current;
const value = email.trim();
if (!field || !value || !field.validity.valid) {
refuse(value ? "That address looks wrong." : "Write your address first.");
return;
}
const step = reduce ? 0 : 1;
setError("");
setReturned(0);
setSent(value);
setDate(new Date().toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "2-digit" }).toUpperCase());
setPhase("sealing");
try {
await Promise.all([onSubmit?.(value), wait(980 * step)]);
} catch {
if (alive.current) refuse("It came back. Please try again.");
return;
}
if (!alive.current) return;
setPhase("stamped");
await wait(560 * step);
if (!alive.current) return;
setPhase("posting");
await wait(1020 * step);
if (!alive.current) return;
setLetters((n) => n + 1);
setPhase("posted");
};
const reset = () => {
setEmail("");
setPhase("open");
requestAnimationFrame(() => input.current?.focus());
};
const closed = phase !== "open";
const posting = phase === "posting";
const stamped = phase === "stamped" || posting;
return (
<div className={`@container ${className}`}>
<div className="relative overflow-hidden rounded-[1.6cqw]" style={{ paddingTop: "72cqw", background: "linear-gradient(180deg, #17524c, #123f3b 78%, #0c2c2a 78.4%, #0a2422)" }}>
{/* Soft wall light. */}
<div className="absolute inset-0" style={{ background: "radial-gradient(70% 70% at 36% 26%, #ffffff1c, transparent 70%)" }} aria-hidden />
{/* Post box. */}
<motion.div
className="absolute right-[2.6cqw] bottom-[3cqw] z-30 h-[52cqw] w-[18cqw] origin-bottom"
animate={phase === "posted" && !reduce ? { scaleY: [1, 0.975, 1.012, 1] } : { scaleY: 1 }}
transition={{ duration: 0.36 }}
aria-hidden
>
<div className="absolute inset-x-[-1cqw] top-0 h-[6.4cqw] rounded-t-[9cqw] rounded-b-[1cqw]" style={{ background: "linear-gradient(90deg, #6d120c, #d7362a 42%, #c02a20 60%, #5e0f0a)" }} />
<div className="absolute inset-x-0 top-[5.8cqw] bottom-[3cqw]" style={{ background: "linear-gradient(90deg, #7d150e, #dc3a2d 40%, #c42c22 62%, #68110b)" }} />
<div className="absolute inset-x-[-1cqw] bottom-0 h-[3.4cqw] rounded-[0.8cqw]" style={{ background: "linear-gradient(90deg, #17181b, #3c3f45 45%, #17181b)" }} />
<div className="absolute inset-x-[2.4cqw] top-[10.6cqw] h-[2.4cqw] rounded-[0.6cqw]" style={{ background: "#120605", boxShadow: "0 0.5cqw 0 #ef6a5b, 0 -0.6cqw 0 #6e120c" }} />
<p className="absolute inset-x-0 top-[15.4cqw] m-0 text-center font-mono text-[1.9cqw] font-bold tracking-[0.14em]" style={{ color: "#f7dc9a" }}>LETTERS</p>
<div className="absolute inset-x-[3cqw] top-[20cqw] rounded-[0.5cqw] px-[0.5cqw] py-[0.9cqw] text-center font-mono text-[1.6cqw] leading-[1.25] font-semibold" style={{ background: "#f2ead6", color: "#2b2118" }}>
NEXT
<br />
COLLECT
<br />
<span className="text-[2.2cqw] font-bold">5 PM</span>
</div>
<div className="absolute inset-x-[4.6cqw] bottom-[6cqw] rounded-[0.5cqw] py-[0.4cqw] text-center font-mono text-[2.6cqw] font-bold" style={{ background: "#120605", color: "#f7dc9a" }}>
{letters}
</div>
</motion.div>
<AnimatePresence>
{phase !== "posted" && (
<motion.form
key="envelope"
noValidate
onSubmit={send}
className="absolute top-[5cqw] left-[3cqw] z-20 h-[63cqw] w-[72cqw]"
style={{ transformOrigin: "50% 80%", zIndex: posting ? 40 : undefined }}
animate={posting ? { x: ["0cqw", "17cqw", "49.4cqw", "49.4cqw"], y: ["0cqw", "-14cqw", "-27cqw", "-27cqw"], scale: [1, 0.6, 0.16, 0.16], scaleY: [1, 1, 1, 0.04], rotate: [0, -5, -2, 0], opacity: [1, 1, 1, 0] } : { x: "0cqw", y: "0cqw", scale: 1, scaleY: 1, rotate: 0, opacity: 1 }}
transition={posting ? { duration: reduce ? 0 : 0.98, times: [0, 0.4, 0.72, 1], ease: "easeInOut" } : { duration: 0 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
>
<motion.div className="absolute inset-0" animate={shake}>
{/* Back of the envelope, and its flap while open. */}
<div className="absolute inset-x-0 top-[27cqw] bottom-[0] rounded-[1cqw]" style={{ background: "#c9b48e" }} aria-hidden />
<motion.div
className="absolute inset-x-0 top-[12cqw] h-[15.2cqw] origin-bottom"
animate={{ rotateX: closed ? 180 : 0, opacity: closed ? 0 : 1 }}
transition={{ duration: reduce ? 0 : 0.01, delay: reduce ? 0 : 0.9 }}
style={{ clipPath: "polygon(0 100%, 50% 0, 100% 100%)", background: "linear-gradient(0deg, #d8c6a2, #bfa981)" }}
aria-hidden
/>
{/* The letter. Clipped at the foot so all of it can go in. */}
<div className="absolute inset-0 overflow-hidden">
<motion.div
className="absolute inset-x-[4cqw] top-0 h-[44cqw] rounded-[0.6cqw] px-[4cqw] pt-[2.6cqw]"
animate={{ y: closed ? "29cqw" : "0cqw" }}
transition={{ duration: reduce ? 0 : 0.5, ease: [0.5, 0, 0.3, 1] }}
style={{ background: "linear-gradient(180deg, #fffdf6, #f5efdf)", color: "#2a2119", boxShadow: "0 0.3cqw 1cqw #0004" }}
>
<h3 className="m-0 text-[5.3cqw] leading-[1.1] font-bold" style={{ fontFamily: "Georgia, 'Times New Roman', serif" }}>{title}</h3>
<p className="m-0 mt-[1.2cqw] text-[3cqw] leading-[1.3]" style={{ color: "#5b4f43" }}>{subtitle}</p>
<div className="mt-[1.8cqw] space-y-[1.7cqw]" aria-hidden>
{[92, 74].map((w) => (
<span key={w} className="block h-[0.24cqw]" style={{ width: `${w}%`, background: "#a9c3d6" }} />
))}
</div>
</motion.div>
</div>
{/* Front pocket, with the address on it. */}
<div
className="absolute inset-x-0 top-[27cqw] bottom-0 rounded-b-[1cqw]"
style={{ clipPath: "polygon(0 0, 50% 42%, 100% 0, 100% 100%, 0 100%)", background: "linear-gradient(180deg, #ede0c6, #e2d2b1)", filter: "none" }}
>
<div className="absolute inset-x-0 bottom-0 h-[1.5cqw]" style={{ background: "repeating-linear-gradient(115deg, #c8321f 0 2.6cqw, #efe3cb 2.6cqw 4.2cqw, #27306b 4.2cqw 6.8cqw, #efe3cb 6.8cqw 8.4cqw)" }} aria-hidden />
</div>
{/* The flap once it has folded down. */}
<motion.div
className="absolute inset-x-0 top-[27cqw] h-[15.2cqw] origin-top"
initial={false}
animate={{ rotateX: closed ? 0 : -180, opacity: closed ? 1 : 0 }}
transition={{ rotateX: { duration: reduce ? 0 : 0.42, delay: reduce ? 0 : 0.46, ease: "easeIn" }, opacity: { duration: 0, delay: reduce ? 0 : 0.46 } }}
style={{ clipPath: "polygon(0 0, 100% 0, 50% 100%)", background: "linear-gradient(180deg, #ddcba8, #cfbb95)", transformPerspective: "120cqw" }}
aria-hidden
/>
{/* Stamp. */}
<div className="absolute top-[28.6cqw] right-[2.4cqw] grid h-[8.8cqw] w-[7.4cqw] place-items-center" style={{ background: "#fbf6e8", padding: "0.5cqw", clipPath: "polygon(0 4%, 4% 0, 96% 0, 100% 4%, 100% 96%, 96% 100%, 4% 100%, 0 96%)" }} aria-hidden>
<span className="grid size-full place-items-center font-mono text-[2.5cqw] font-bold" style={{ background: "#c8321f", color: "#fbf6e8" }}>โน5</span>
</div>
<AnimatePresence>
{stamped && (
<motion.svg
viewBox="0 0 100 100"
className="absolute top-[26.8cqw] right-[5.2cqw] size-[13cqw]"
initial={reduce ? false : { scale: 1.7, opacity: 0 }}
animate={{ scale: 1, opacity: 0.86, rotate: -14 }}
transition={{ duration: 0.16, ease: "easeIn" }}
aria-hidden
>
<g fill="none" stroke="#27306b" strokeWidth="3">
<circle cx="50" cy="50" r="46" />
<circle cx="50" cy="50" r="31" />
</g>
<defs>
<path id={`${id}arc`} d="M12 50 a38 38 0 0 1 76 0" />
</defs>
<text fontSize="11" fontWeight="700" fill="#27306b" fontFamily="ui-monospace, monospace" letterSpacing="2">
<textPath href={`#${id}arc`} startOffset="50%" textAnchor="middle">POSTED</textPath>
</text>
<text x="50" y="55" textAnchor="middle" fontSize="13" fontWeight="800" fill="#27306b" fontFamily="ui-monospace, monospace">{date}</text>
</motion.svg>
)}
</AnimatePresence>
{/* Address line, the real field. */}
<div className="absolute inset-x-[3cqw] top-[43.6cqw]">
<label className="flex items-end gap-[1.6cqw]" style={{ color: "#3a2f22", fontFamily: "'Segoe Print', 'Bradley Hand', 'Comic Sans MS', cursive" }}>
<span className="text-[3.1cqw] leading-[1.5]">To</span>
<input
ref={input}
type="email"
name={name}
required
autoComplete="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError("");
}}
readOnly={closed}
placeholder={placeholder}
aria-label="Your email address"
aria-invalid={!!error}
aria-describedby={error ? `${id}error` : undefined}
className="min-w-0 flex-1 border-b-[0.3cqw] border-dashed bg-transparent pb-[0.3cqw] text-[3.5cqw] leading-[1.45] outline-none placeholder:text-[#7c6c58] focus:border-solid"
style={{ borderColor: "#3a2f22" }}
/>
</label>
<div className="mt-[1.8cqw] flex items-center justify-between gap-[2cqw]">
<p id={`${id}error`} role="alert" className="m-0 min-w-0 text-[2.9cqw] leading-[1.15] font-semibold" style={{ color: "#a0241a" }}>{error}</p>
<button
type="submit"
disabled={closed}
className="min-h-6 shrink-0 cursor-pointer rounded-[0.7cqw] px-[3.6cqw] py-[1.1cqw] font-mono text-[3.2cqw] font-bold disabled:cursor-default disabled:opacity-60"
style={{ background: "#c8321f", color: "#fff7ea", boxShadow: "0 0.5cqw 0 #7d150e" }}
>
{closed ? "Sealed" : buttonLabel}
</button>
</div>
</div>
{/* Sent back. */}
<AnimatePresence>
{returned > 0 && !closed && error && (
<motion.span
key={returned}
className="absolute top-[30cqw] left-[2.6cqw] rounded-[0.5cqw] border-[0.5cqw] px-[1.2cqw] py-[0.3cqw] font-mono text-[2.5cqw] font-extrabold tracking-[0.06em]"
style={{ color: "#a0241a", borderColor: "#a0241a", rotate: -9 }}
initial={reduce ? false : { scale: 1.6, opacity: 0 }}
animate={{ scale: 1, opacity: 0.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeIn" }}
aria-hidden
>
RETURN TO SENDER
</motion.span>
)}
</AnimatePresence>
</motion.div>
</motion.form>
)}
</AnimatePresence>
<AnimatePresence>
{phase === "posted" && (
<motion.div
key="slip"
className="absolute top-[16cqw] left-[6cqw] z-20 w-[62cqw] rounded-[0.6cqw] px-[4cqw] py-[3.4cqw]"
style={{ background: "#f6c9d2", color: "#2a1a1e", boxShadow: "0 0.8cqw 2cqw #0007" }}
initial={reduce ? false : { y: "6cqw", rotate: 3, opacity: 0 }}
animate={{ y: "0cqw", rotate: -1.4, opacity: 1 }}
transition={{ duration: 0.34, ease: "easeOut" }}
>
<p className="m-0 font-mono text-[2.4cqw] font-bold tracking-[0.18em]">RECEIPT OF POSTING</p>
<p role="status" className="m-0 mt-[1.2cqw] text-[4.4cqw] leading-[1.15] font-bold" style={{ fontFamily: "Georgia, 'Times New Roman', serif" }}>Posted. Watch your inbox.</p>
<p className="m-0 mt-[1cqw] truncate font-mono text-[2.8cqw]">{sent}</p>
<button ref={again} type="button" onClick={reset} className="mt-[1.8cqw] min-h-6 cursor-pointer font-mono text-[2.8cqw] font-bold underline underline-offset-[0.5cqw]">
Use another address
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/envelope-signup.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/envelope-signup-demo.tsx
import { EnvelopeSignup } from "@/components/ui/envelope-signup"; export default function EnvelopeSignupExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#0c1c1b] p-2"> <EnvelopeSignup className="w-[min(136cqh,96cqw,680px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | "One good letter a month" | Printed on the letter. |
| subtitle | string | short line | Small line on the letter. |
| placeholder | string | "you@example.com" | Hint inside the address line. |
| buttonLabel | string | "Send" | Words on the button. |
| name | string | "email" | Field name. |
| onSubmit | (email: string) => void | Promise<void> | - | Your signup call. A rejected promise sends the letter back. |
| className | string | "w-full max-w-xl" | Set the width here. Everything scales with it. |