Postal
import { useId, useState } from 'react';
const N = 6;
const css = `
@keyframes field-postal-stamp { 0% { transform: scale(1.5) rotate(-8deg); opacity: 0; } 55% { transform: scale(0.92) rotate(2deg); opacity: 1; } 100% { transform: rotate(var(--r)); opacity: 1; } }
@keyframes field-postal-frank { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
.field-postal { width: 260px; }
.field-postal__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-postal__row { position: relative; display: flex; gap: 6px; cursor: text; }
.field-postal__input { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; border: 0; opacity: 0; font-size: 16px; }
/* A stamp: perforated edge from a scalloped mask, a printed frame inside. */
.field-postal__stamp {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 44px;
background: light-dark(#ebedf0, #232529);
-webkit-mask: radial-gradient(circle at 3px 3px, transparent 1.6px, #000 1.8px) -3px -3px / 6px 6px, linear-gradient(#000, #000) 3px 3px / calc(100% - 6px) calc(100% - 6px) no-repeat;
mask: radial-gradient(circle at 3px 3px, transparent 1.6px, #000 1.8px) -3px -3px / 6px 6px, linear-gradient(#000, #000) 3px 3px / calc(100% - 6px) calc(100% - 6px) no-repeat;
}
.field-postal__stamp::before { content: ''; position: absolute; inset: 5px; border: 1px solid light-dark(#cbced3, #393c41); }
.field-postal__stamp[data-next] { background: light-dark(#dbdde1, #2e3035); }
.field-postal__stamp[data-next]::after { content: ''; position: absolute; bottom: 9px; width: 12px; height: 1.5px; background: light-dark(#50535b, #c6c8cd); }
.field-postal__ch { position: relative; color: light-dark(#373a40, #f1f2f3); font: 600 18px/1 ui-monospace, SFMono-Regular, Menlo, monospace; transform: rotate(var(--r)); animation: field-postal-stamp 0.3s cubic-bezier(0.4, 0, 0.4, 1) both; }
.field-postal__frank { position: absolute; left: -4px; right: -4px; top: 6px; height: 34px; width: calc(100% + 8px); overflow: visible; pointer-events: none; }
.field-postal__frank path { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.3; stroke-dasharray: 1; animation: field-postal-frank 0.6s ease-out both; }
.field-postal__frank path + path { animation-delay: 0.08s; }
.field-postal__row:focus-within .field-postal__stamp[data-next] { background: light-dark(#cbced3, #393c41); }
.field-postal__row:focus-within { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 6px; border-radius: 4px; }
@media (prefers-reduced-motion: reduce) {
.field-postal__ch, .field-postal__frank path { animation: none !important; }
}
`;
export function PostalInput() {
const id = useId();
const [v, setV] = useState('');
const full = v.length === N;
return (
<>
<style>
{css}
</style>
<div className="field-postal">
<label className="field-postal__label" htmlFor={id}>
Postcode
</label>
<div className="field-postal__row">
{Array.from({ length: N }, (_, i) => (
<span key={i} className="field-postal__stamp" data-next={i === v.length || undefined} aria-hidden="true">
{v[i] && (
<span key={v[i] + i} className="field-postal__ch" style={{ '--r': `${((i * 7) % 5) - 2}deg` } as React.CSSProperties}>
{v[i]}
</span>
)}
</span>
))}
{full && (
<svg className="field-postal__frank" viewBox="0 0 260 34" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 12C20 6 40 18 60 12S100 6 120 12S160 18 180 12S220 6 260 12" pathLength={1} />
<path d="M0 22C20 16 40 28 60 22S100 16 120 22S160 28 180 22S220 16 260 22" pathLength={1} />
</svg>
)}
<input
id={id}
className="field-postal__input"
value={v}
maxLength={N}
autoComplete="postal-code"
autoCapitalize="characters"
onChange={(e) => setV(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, N))}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldPostal(el) on the mounted element instead.
<div class="field-postal">
<label class="field-postal__label" for="field-postal">Postcode</label>
<div class="field-postal__row">
<span class="field-postal__stamp" data-next="true" aria-hidden="true"></span>
<span class="field-postal__stamp" aria-hidden="true"></span>
<span class="field-postal__stamp" aria-hidden="true"></span>
<span class="field-postal__stamp" aria-hidden="true"></span>
<span class="field-postal__stamp" aria-hidden="true"></span>
<span class="field-postal__stamp" aria-hidden="true"></span>
<input id="field-postal" class="field-postal__input" maxLength="6" autoComplete="postal-code" autoCapitalize="characters" value=""/>
</div>
</div>@keyframes field-postal-stamp { 0% { transform: scale(1.5) rotate(-8deg); opacity: 0; } 55% { transform: scale(0.92) rotate(2deg); opacity: 1; } 100% { transform: rotate(var(--r)); opacity: 1; } }
@keyframes field-postal-frank { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
.field-postal { width: 260px; }
.field-postal__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-postal__row { position: relative; display: flex; gap: 6px; cursor: text; }
.field-postal__input { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; border: 0; opacity: 0; font-size: 16px; }
/* A stamp: perforated edge from a scalloped mask, a printed frame inside. */
.field-postal__stamp {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 44px;
background: light-dark(#ebedf0, #232529);
-webkit-mask: radial-gradient(circle at 3px 3px, transparent 1.6px, #000 1.8px) -3px -3px / 6px 6px, linear-gradient(#000, #000) 3px 3px / calc(100% - 6px) calc(100% - 6px) no-repeat;
mask: radial-gradient(circle at 3px 3px, transparent 1.6px, #000 1.8px) -3px -3px / 6px 6px, linear-gradient(#000, #000) 3px 3px / calc(100% - 6px) calc(100% - 6px) no-repeat;
}
.field-postal__stamp::before { content: ''; position: absolute; inset: 5px; border: 1px solid light-dark(#cbced3, #393c41); }
.field-postal__stamp[data-next] { background: light-dark(#dbdde1, #2e3035); }
.field-postal__stamp[data-next]::after { content: ''; position: absolute; bottom: 9px; width: 12px; height: 1.5px; background: light-dark(#50535b, #c6c8cd); }
.field-postal__ch { position: relative; color: light-dark(#373a40, #f1f2f3); font: 600 18px/1 ui-monospace, SFMono-Regular, Menlo, monospace; transform: rotate(var(--r)); animation: field-postal-stamp 0.3s cubic-bezier(0.4, 0, 0.4, 1) both; }
.field-postal__frank { position: absolute; left: -4px; right: -4px; top: 6px; height: 34px; width: calc(100% + 8px); overflow: visible; pointer-events: none; }
.field-postal__frank path { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.3; stroke-dasharray: 1; animation: field-postal-frank 0.6s ease-out both; }
.field-postal__frank path + path { animation-delay: 0.08s; }
.field-postal__row:focus-within .field-postal__stamp[data-next] { background: light-dark(#cbced3, #393c41); }
.field-postal__row:focus-within { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 6px; border-radius: 4px; }
@media (prefers-reduced-motion: reduce) {
.field-postal__ch, .field-postal__frank path { animation: none !important; }
}function initFieldPostal(root) {
var N = 6;
var input = root.querySelector('.field-postal__input');
var row = root.querySelector('.field-postal__row');
var stamps = root.querySelectorAll('.field-postal__stamp');
var FRANK = '<path d="M0 12C20 6 40 18 60 12S100 6 120 12S160 18 180 12S220 6 260 12" pathLength="1"></path><path d="M0 22C20 16 40 28 60 22S100 16 120 22S160 28 180 22S220 16 260 22" pathLength="1"></path>';
function update() {
var v = input.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, N);
input.value = v;
stamps.forEach(function (stamp, i) {
stamp.toggleAttribute('data-next', i === v.length);
var ch = stamp.querySelector('.field-postal__ch');
if ((ch ? ch.textContent : '') === (v[i] || '')) return;
// A new character is a new element, so it is stamped down from the start.
if (ch) ch.remove();
if (!v[i]) return;
ch = document.createElement('span');
ch.className = 'field-postal__ch';
ch.style.setProperty('--r', ((i * 7) % 5) - 2 + 'deg');
ch.textContent = v[i];
stamp.appendChild(ch);
});
// All six in: the wavy franking lines are drawn across the row.
var frank = row.querySelector('.field-postal__frank');
if (v.length === N && !frank) {
frank = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
frank.setAttribute('class', 'field-postal__frank');
frank.setAttribute('viewBox', '0 0 260 34');
frank.setAttribute('preserveAspectRatio', 'none');
frank.setAttribute('aria-hidden', 'true');
frank.innerHTML = FRANK;
row.insertBefore(frank, input);
} else if (v.length < N && frank) frank.remove();
}
input.addEventListener('input', update);
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.field-postal').forEach(initFieldPostal);Goal: Create a "Postal" input.
Visual behavior: A postcode field drawn as postage stamps: each character is stamped into its own perforated stamp at a slight angle, and the row is franked with wavy lines once it is complete.
Technique:
- A real input lies over six stamps, transparent; it keeps capitals and digits only and the next empty stamp is marked data-next.
- Each new character is a new element that stamps down with a scale and rotate keyframe to its own fixed tilt (--r); when all six are in, two wavy SVG lines are drawn across the row by dash offset.
- The perforated edge is a scalloped mask on each stamp.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The stamps are aria-hidden, so the field reads as a plain postcode input. Under prefers-reduced-motion the characters and franking appear without motion.
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 "Postal" input.
Visual behavior: A postcode field drawn as postage stamps: each character is stamped into its own perforated stamp at a slight angle, and the row is franked with wavy lines once it is complete.
Technique:
- A real input lies over six stamps, transparent; it keeps capitals and digits only and the next empty stamp is marked data-next.
- Each new character is a new element that stamps down with a scale and rotate keyframe to its own fixed tilt (--r); when all six are in, two wavy SVG lines are drawn across the row by dash offset.
- The perforated edge is a scalloped mask on each stamp.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The stamps are aria-hidden, so the field reads as a plain postcode input. Under prefers-reduced-motion the characters and franking appear without motion.
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 postcode field drawn as postage stamps: each character is stamped into its own perforated stamp at a slight angle, and the row is franked with wavy lines once it is complete.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, draw
- Techniques
- keyframes, stroke dashoffset, svg, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The stamps are aria-hidden, so the field reads as a plain postcode input. Under prefers-reduced-motion the characters and franking appear without motion.