Label maker
import { useId, useState } from 'react';
import type { CSSProperties } from 'react';
const css = `
@keyframes field-emboss-punch { 0% { transform: scale(1.5); opacity: 0; } 40% { transform: scale(0.88); opacity: 1; } 100% { transform: none; } }
@keyframes field-emboss-squeeze-a { 0%, 100% { transform: translateY(0); } 35% { transform: translateY(-5px); } }
@keyframes field-emboss-squeeze-b { 0%, 100% { transform: translateY(0); } 35% { transform: translateY(-5px); } }
.field-emboss { width: 300px; }
.field-emboss__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-emboss__row { position: relative; display: flex; align-items: center; height: 64px; }
.field-emboss__maker { flex: none; width: 64px; height: 64px; overflow: visible; }
.field-emboss__body { fill: light-dark(#cbced3, #393c41); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; }
.field-emboss__slot { fill: light-dark(#fbfcfd, #121417); }
.field-emboss__wheel { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.2; }
.field-emboss__notches { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2.5; stroke-dasharray: 0.8 2.12; }
.field-emboss__dial { transform-box: view-box; transform-origin: 25px 28px; transition: transform 0.3s cubic-bezier(0.3, 1.4, 0.5, 1); }
.field-emboss__mark { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2; stroke-linecap: round; }
.field-emboss__hub { fill: light-dark(#5f626a, #a7abb2); }
.field-emboss__pointer { fill: light-dark(#42454c, #e5e6e9); }
/* The window over the wheel shows the letter it is set to. */
.field-emboss__window { fill: light-dark(#fbfcfd, #e5e6e9); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.field-emboss__glyph { fill: light-dark(#373a40, #232529); font: 700 9px ui-monospace, SFMono-Regular, Menlo, monospace; text-anchor: middle; }
.field-emboss__grip { fill: light-dark(#6d7179, #7d8087); }
.field-emboss__maker[data-press='a'] .field-emboss__handle { animation: field-emboss-squeeze-a 0.22s ease-out; }
.field-emboss__maker[data-press='b'] .field-emboss__handle { animation: field-emboss-squeeze-b 0.22s ease-out; }
/* The tape: dark with raised pale letters in both themes, as the real thing is. */
.field-emboss__tape {
position: relative;
height: 34px;
margin-left: -6px;
border-radius: 2px 6px 6px 2px;
background: light-dark(#42454c, #2e3035);
box-shadow: 0 2px 4px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25));
font: 700 16px ui-monospace, SFMono-Regular, Menlo, monospace;
transition: width 0.18s ease-out;
}
.field-emboss__input, .field-emboss__mirror {
box-sizing: border-box;
padding: 0 14px;
font: 700 16px/34px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 3px;
font-kerning: none;
font-variant-ligatures: none;
text-transform: uppercase;
}
.field-emboss__input {
position: absolute;
left: 0;
top: 0;
width: 260px;
height: 34px;
margin: 0;
border: 0;
outline: none;
background: transparent;
color: transparent;
caret-color: light-dark(#fbfcfd, #f1f2f3);
}
.field-emboss__input::placeholder { color: light-dark(#cbced3, #7d8087); }
.field-emboss__input::selection { background: light-dark(#6d7179, #7d8087); }
.field-emboss__mirror { position: absolute; inset: 0; white-space: pre; pointer-events: none; color: light-dark(#fbfcfd, #f1f2f3); }
.field-emboss__ch { display: inline-block; text-shadow: 0 1px 0 light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), 0 -1px 0 color-mix(in srgb, light-dark(#373a40, #f1f2f3) 25%, transparent); }
.field-emboss__ch--new { animation: field-emboss-punch 0.28s cubic-bezier(0.3, 0, 0.3, 1); }
.field-emboss__row:focus-within .field-emboss__tape { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.field-emboss__tape, .field-emboss__dial { transition: none; }
.field-emboss__ch--new, .field-emboss__maker .field-emboss__handle { animation: none !important; }
}
`;
const MAX = 16;
const WHEEL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function EmbossInput() {
const id = useId();
const [v, setV] = useState('');
const [grew, setGrew] = useState(false);
const [press, setPress] = useState<'a' | 'b' | undefined>();
// The wheel turns to the last letter, a tenth of a turn per step round the dial.
const last = v.slice(-1);
const turn = WHEEL.indexOf(last) * 10;
// Room for the placeholder while it is empty, then the tape grows a letter at a time.
const cells = Math.max(v.length + 1, v ? 0 : 11);
return (
<>
<style>
{css}
</style>
<div className="field-emboss">
<label className="field-emboss__label" htmlFor={id}>
Label
</label>
<div className="field-emboss__row">
<svg className="field-emboss__maker" viewBox="0 0 64 64" data-press={press} aria-hidden="true">
<g className="field-emboss__handle">
<rect className="field-emboss__grip" x="12" y="40" width="34" height="18" rx="9" />
</g>
<rect className="field-emboss__body" x="2" y="8" width="60" height="40" rx="14" />
<rect className="field-emboss__slot" x="56" y="24" width="6" height="8" rx="1" />
<circle className="field-emboss__wheel" cx="25" cy="28" r="15" />
<g className="field-emboss__dial" style={{ transform: `rotate(${turn}deg)` }}>
<circle className="field-emboss__notches" cx="25" cy="28" r="12" pathLength={36} />
<path className="field-emboss__mark" d="M25 20V24" />
</g>
<circle className="field-emboss__hub" cx="25" cy="28" r="3" />
<rect className="field-emboss__window" x="18" y="0" width="14" height="13" rx="3" />
<text className="field-emboss__glyph" x="25" y="9.6">
{last.trim() || 'A'}
</text>
<path className="field-emboss__pointer" d="M23 13H27L25 16Z" />
</svg>
<div className="field-emboss__tape" style={{ width: `calc(${cells} * (1ch + 3px) + 28px)` } as CSSProperties}>
<span className="field-emboss__mirror" aria-hidden="true">
{[...v].map((ch, i) => (
<span key={`${i}${ch}`} className={`field-emboss__ch${grew && i === v.length - 1 ? ' field-emboss__ch--new' : ''}`}>
{ch}
</span>
))}
</span>
<input
id={id}
className="field-emboss__input"
value={v}
maxLength={MAX}
placeholder="Name a box"
autoComplete="off"
spellCheck={false}
onChange={(e) => {
const next = e.target.value
.toUpperCase()
.replace(/[^A-Z0-9 .&-]/g, '')
.slice(0, MAX);
if (next !== v) setPress((p) => (p === 'a' ? 'b' : 'a'));
setGrew(next.length > v.length);
setV(next);
}}
/>
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldEmboss(el) on the mounted element instead.
<div class="field-emboss">
<label class="field-emboss__label" for="field-emboss">Label</label>
<div class="field-emboss__row">
<svg class="field-emboss__maker" viewBox="0 0 64 64" aria-hidden="true">
<g class="field-emboss__handle">
<rect class="field-emboss__grip" x="12" y="40" width="34" height="18" rx="9"></rect>
</g>
<rect class="field-emboss__body" x="2" y="8" width="60" height="40" rx="14"></rect>
<rect class="field-emboss__slot" x="56" y="24" width="6" height="8" rx="1"></rect>
<circle class="field-emboss__wheel" cx="25" cy="28" r="15"></circle>
<g class="field-emboss__dial" style="transform:rotate(0deg)">
<circle class="field-emboss__notches" cx="25" cy="28" r="12" pathLength="36"></circle>
<path class="field-emboss__mark" d="M25 20V24"></path>
</g>
<circle class="field-emboss__hub" cx="25" cy="28" r="3"></circle>
<rect class="field-emboss__window" x="18" y="0" width="14" height="13" rx="3"></rect>
<text class="field-emboss__glyph" x="25" y="9.6">A</text>
<path class="field-emboss__pointer" d="M23 13H27L25 16Z"></path>
</svg>
<div class="field-emboss__tape" style="width:calc(11 * (1ch + 3px) + 28px)">
<span class="field-emboss__mirror" aria-hidden="true"></span>
<input id="field-emboss" class="field-emboss__input" maxLength="16" placeholder="Name a box" autoComplete="off" spellCheck="false" value=""/>
</div>
</div>
</div>@keyframes field-emboss-punch { 0% { transform: scale(1.5); opacity: 0; } 40% { transform: scale(0.88); opacity: 1; } 100% { transform: none; } }
@keyframes field-emboss-squeeze-a { 0%, 100% { transform: translateY(0); } 35% { transform: translateY(-5px); } }
@keyframes field-emboss-squeeze-b { 0%, 100% { transform: translateY(0); } 35% { transform: translateY(-5px); } }
.field-emboss { width: 300px; }
.field-emboss__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-emboss__row { position: relative; display: flex; align-items: center; height: 64px; }
.field-emboss__maker { flex: none; width: 64px; height: 64px; overflow: visible; }
.field-emboss__body { fill: light-dark(#cbced3, #393c41); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; }
.field-emboss__slot { fill: light-dark(#fbfcfd, #121417); }
.field-emboss__wheel { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.2; }
.field-emboss__notches { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2.5; stroke-dasharray: 0.8 2.12; }
.field-emboss__dial { transform-box: view-box; transform-origin: 25px 28px; transition: transform 0.3s cubic-bezier(0.3, 1.4, 0.5, 1); }
.field-emboss__mark { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2; stroke-linecap: round; }
.field-emboss__hub { fill: light-dark(#5f626a, #a7abb2); }
.field-emboss__pointer { fill: light-dark(#42454c, #e5e6e9); }
/* The window over the wheel shows the letter it is set to. */
.field-emboss__window { fill: light-dark(#fbfcfd, #e5e6e9); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.field-emboss__glyph { fill: light-dark(#373a40, #232529); font: 700 9px ui-monospace, SFMono-Regular, Menlo, monospace; text-anchor: middle; }
.field-emboss__grip { fill: light-dark(#6d7179, #7d8087); }
.field-emboss__maker[data-press='a'] .field-emboss__handle { animation: field-emboss-squeeze-a 0.22s ease-out; }
.field-emboss__maker[data-press='b'] .field-emboss__handle { animation: field-emboss-squeeze-b 0.22s ease-out; }
/* The tape: dark with raised pale letters in both themes, as the real thing is. */
.field-emboss__tape {
position: relative;
height: 34px;
margin-left: -6px;
border-radius: 2px 6px 6px 2px;
background: light-dark(#42454c, #2e3035);
box-shadow: 0 2px 4px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25));
font: 700 16px ui-monospace, SFMono-Regular, Menlo, monospace;
transition: width 0.18s ease-out;
}
.field-emboss__input, .field-emboss__mirror {
box-sizing: border-box;
padding: 0 14px;
font: 700 16px/34px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 3px;
font-kerning: none;
font-variant-ligatures: none;
text-transform: uppercase;
}
.field-emboss__input {
position: absolute;
left: 0;
top: 0;
width: 260px;
height: 34px;
margin: 0;
border: 0;
outline: none;
background: transparent;
color: transparent;
caret-color: light-dark(#fbfcfd, #f1f2f3);
}
.field-emboss__input::placeholder { color: light-dark(#cbced3, #7d8087); }
.field-emboss__input::selection { background: light-dark(#6d7179, #7d8087); }
.field-emboss__mirror { position: absolute; inset: 0; white-space: pre; pointer-events: none; color: light-dark(#fbfcfd, #f1f2f3); }
.field-emboss__ch { display: inline-block; text-shadow: 0 1px 0 light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), 0 -1px 0 color-mix(in srgb, light-dark(#373a40, #f1f2f3) 25%, transparent); }
.field-emboss__ch--new { animation: field-emboss-punch 0.28s cubic-bezier(0.3, 0, 0.3, 1); }
.field-emboss__row:focus-within .field-emboss__tape { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.field-emboss__tape, .field-emboss__dial { transition: none; }
.field-emboss__ch--new, .field-emboss__maker .field-emboss__handle { animation: none !important; }
}function initFieldEmboss(root) {
var MAX = 16;
var WHEEL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
var input = root.querySelector('.field-emboss__input');
var mirror = root.querySelector('.field-emboss__mirror');
var tape = root.querySelector('.field-emboss__tape');
var maker = root.querySelector('.field-emboss__maker');
var dial = root.querySelector('.field-emboss__dial');
var glyph = root.querySelector('.field-emboss__glyph');
var v = '';
var press = '';
// One span per letter. A letter that is new is a new element, so its punch plays from the start.
function paint(grew) {
for (var i = 0; i < v.length; i++) {
var span = mirror.children[i];
if (span && span.textContent === v[i]) {
span.classList.remove('field-emboss__ch--new');
continue;
}
var fresh = document.createElement('span');
fresh.className = 'field-emboss__ch';
fresh.textContent = v[i];
if (span) mirror.replaceChild(fresh, span);
else mirror.appendChild(fresh);
}
while (mirror.children.length > v.length) mirror.lastChild.remove();
if (grew && mirror.lastChild) mirror.lastChild.classList.add('field-emboss__ch--new');
// The wheel turns to the last letter, a tenth of a turn per step round the dial.
var last = v.slice(-1);
dial.style.transform = 'rotate(' + WHEEL.indexOf(last) * 10 + 'deg)';
glyph.textContent = last.trim() || 'A';
// Room for the placeholder while it is empty, then the tape grows a letter at a time.
var cells = Math.max(v.length + 1, v ? 0 : 11);
tape.style.width = 'calc(' + cells + ' * (1ch + 3px) + 28px)';
}
function onInput() {
var pos = input.selectionStart;
var next = input.value
.toUpperCase()
.replace(/[^A-Z0-9 .&-]/g, '')
.slice(0, MAX);
if (input.value !== next) {
input.value = next;
input.setSelectionRange(pos, pos);
}
if (next === v) return;
// Flip between two identical keyframes so the handle squeezes on every letter.
press = press === 'a' ? 'b' : 'a';
maker.setAttribute('data-press', press);
var grew = next.length > v.length;
v = next;
paint(grew);
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-emboss').forEach(initFieldEmboss);Goal: Create a "Label maker" input.
Visual behavior: A label field typed on a label maker: the letter wheel turns to each letter, the handle squeezes, and the letter is punched up out of the tape as it feeds out longer.
Technique:
- A real input with transparent text sits under a mirror of the value set in the same font and metrics, so the caret, selection and typing all still work while the mirror draws the letters.
- The tape's width is set in ch units from the length, so it grows a letter at a time. The newest letter plays a punch keyframe, the wheel turns to it while a window over the wheel shows it, and two identical squeeze keyframes alternate so the handle presses on every letter.
Accessibility: A real input with a visible <label>; the mirror and the label maker are aria-hidden. Under prefers-reduced-motion letters appear without the punch and nothing turns.
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 "Label maker" input.
Visual behavior: A label field typed on a label maker: the letter wheel turns to each letter, the handle squeezes, and the letter is punched up out of the tape as it feeds out longer.
Technique:
- A real input with transparent text sits under a mirror of the value set in the same font and metrics, so the caret, selection and typing all still work while the mirror draws the letters.
- The tape's width is set in ch units from the length, so it grows a letter at a time. The newest letter plays a punch keyframe, the wheel turns to it while a window over the wheel shows it, and two identical squeeze keyframes alternate so the handle presses on every letter.
Accessibility: A real input with a visible <label>; the mirror and the label maker are aria-hidden. Under prefers-reduced-motion letters appear without the punch and nothing turns.
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 label field typed on a label maker: the letter wheel turns to each letter, the handle squeezes, and the letter is punched up out of the tape as it feeds out longer.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, keyframe
- Techniques
- keyframes, css transition, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real input with a visible <label>; the mirror and the label maker are aria-hidden. Under prefers-reduced-motion letters appear without the punch and nothing turns.