Haiku
import { useId, useState } from 'react';
const LINES = [
{ target: 5, ph: 'Morning light on glass' },
{ target: 7, ph: 'the kettle hums a low tune' },
{ target: 5, ph: 'steam writes, then is gone' },
];
// An English syllable estimate: vowel groups, less a silent ending. Good enough to count beads by.
function syllables(text: string) {
return text
.toLowerCase()
.split(/[^a-z']+/)
.filter(Boolean)
.reduce((n, w) => {
if (w.length <= 3) return n + 1;
const core = w.replace(/(?:[^laeiouy]es|[^laeiouy]ed|[^laeiouy]e)$/, '').replace(/^y/, '');
return n + Math.max(1, (core.match(/[aeiouy]{1,2}/g) ?? []).length);
}, 0);
}
const css = `
@keyframes field-haiku-seal { 0% { transform: rotate(-30deg) scale(1.8); opacity: 0; } 60% { transform: rotate(-8deg) scale(0.94); opacity: 1; } 100% { transform: rotate(-10deg) scale(1); opacity: 1; } }
.field-haiku { position: relative; display: grid; gap: 2px; width: 280px; }
.field-haiku__row { display: flex; align-items: center; gap: 10px; border-bottom: 1px solid light-dark(#dbdde1, #2e3035); transition: border-color 0.2s; }
.field-haiku__row:focus-within { border-color: light-dark(#5f626a, #a7abb2); }
.field-haiku__input { flex: 1; min-width: 0; height: 36px; padding: 0 2px; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font-family: Georgia, 'Times New Roman', serif; font-size: 13.5px; font-style: italic; }
.field-haiku__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-haiku__beads { display: flex; gap: 2.5px; }
.field-haiku__bead { width: 6px; height: 6px; border-radius: 50%; box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087); transition: background-color 0.2s, transform 0.3s cubic-bezier(0.3, 1.8, 0.5, 1); }
.field-haiku__bead[data-on] { background: light-dark(#42454c, #e5e6e9); box-shadow: none; transform: scale(1.15); }
.field-haiku__row[data-over] .field-haiku__bead { background: light-dark(#6d7179, #7d8087); }
.field-haiku__over { min-width: 16px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-variant-numeric: tabular-nums; }
.field-haiku__seal {
position: absolute;
right: -4px;
top: -30px;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 6px;
box-shadow: inset 0 0 0 2px light-dark(#50535b, #c6c8cd);
color: light-dark(#50535b, #c6c8cd);
font-family: Georgia, 'Times New Roman', serif;
font-size: 10px;
letter-spacing: 0.08em;
line-height: 1.1;
text-align: center;
opacity: 0;
transform: rotate(-10deg);
}
.field-haiku[data-done] .field-haiku__seal { animation: field-haiku-seal 0.45s cubic-bezier(0.4, 0, 0.4, 1) both; }
@media (prefers-reduced-motion: reduce) {
.field-haiku__seal { animation: none !important; }
.field-haiku[data-done] .field-haiku__seal { opacity: 1; }
}
`;
export function HaikuInput() {
const id = useId();
const [lines, setLines] = useState(['', '', '']);
const counts = lines.map(syllables);
const done = counts.every((c, i) => c === LINES[i]!.target);
return (
<>
<style>
{css}
</style>
<div className="field-haiku" role="group" aria-label="Write a haiku" data-done={done || undefined}>
{LINES.map((l, i) => {
const c = counts[i]!;
return (
<div key={i} className="field-haiku__row" data-over={c > l.target || undefined}>
<input
className="field-haiku__input"
aria-label={`Line ${i + 1}, ${l.target} syllables`}
aria-describedby={`${id}-c${i}`}
placeholder={l.ph}
value={lines[i]}
onChange={(e) => setLines((ls) => ls.map((v, k) => (k === i ? e.target.value : v)))}
/>
<span className="field-haiku__beads" aria-hidden="true">
{Array.from({ length: l.target }, (_, k) => (
<span key={k} className="field-haiku__bead" data-on={k < c || undefined} />
))}
</span>
<span id={`${id}-c${i}`} className="field-haiku__over">
{c > l.target ? `+${c - l.target}` : <span style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }}>{`${c} of ${l.target} syllables`}</span>}
</span>
</div>
);
})}
<span className="field-haiku__seal" aria-live="polite">
{done ? (
<>
HAI
<br />
KU
</>
) : null}
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldHaiku(el) on the mounted element instead.
<div class="field-haiku" role="group" aria-label="Write a haiku">
<div class="field-haiku__row">
<input class="field-haiku__input" aria-label="Line 1, 5 syllables" aria-describedby="field-haiku-c0" placeholder="Morning light on glass" value=""/>
<span class="field-haiku__beads" aria-hidden="true">
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
</span>
<span id="field-haiku-c0" class="field-haiku__over">
<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">0 of 5 syllables</span>
</span>
</div>
<div class="field-haiku__row">
<input class="field-haiku__input" aria-label="Line 2, 7 syllables" aria-describedby="field-haiku-c1" placeholder="the kettle hums a low tune" value=""/>
<span class="field-haiku__beads" aria-hidden="true">
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
</span>
<span id="field-haiku-c1" class="field-haiku__over">
<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">0 of 7 syllables</span>
</span>
</div>
<div class="field-haiku__row">
<input class="field-haiku__input" aria-label="Line 3, 5 syllables" aria-describedby="field-haiku-c2" placeholder="steam writes, then is gone" value=""/>
<span class="field-haiku__beads" aria-hidden="true">
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
<span class="field-haiku__bead"></span>
</span>
<span id="field-haiku-c2" class="field-haiku__over">
<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">0 of 5 syllables</span>
</span>
</div>
<span class="field-haiku__seal" aria-live="polite"></span>
</div>@keyframes field-haiku-seal { 0% { transform: rotate(-30deg) scale(1.8); opacity: 0; } 60% { transform: rotate(-8deg) scale(0.94); opacity: 1; } 100% { transform: rotate(-10deg) scale(1); opacity: 1; } }
.field-haiku { position: relative; display: grid; gap: 2px; width: 280px; }
.field-haiku__row { display: flex; align-items: center; gap: 10px; border-bottom: 1px solid light-dark(#dbdde1, #2e3035); transition: border-color 0.2s; }
.field-haiku__row:focus-within { border-color: light-dark(#5f626a, #a7abb2); }
.field-haiku__input { flex: 1; min-width: 0; height: 36px; padding: 0 2px; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font-family: Georgia, 'Times New Roman', serif; font-size: 13.5px; font-style: italic; }
.field-haiku__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-haiku__beads { display: flex; gap: 2.5px; }
.field-haiku__bead { width: 6px; height: 6px; border-radius: 50%; box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087); transition: background-color 0.2s, transform 0.3s cubic-bezier(0.3, 1.8, 0.5, 1); }
.field-haiku__bead[data-on] { background: light-dark(#42454c, #e5e6e9); box-shadow: none; transform: scale(1.15); }
.field-haiku__row[data-over] .field-haiku__bead { background: light-dark(#6d7179, #7d8087); }
.field-haiku__over { min-width: 16px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-variant-numeric: tabular-nums; }
.field-haiku__seal {
position: absolute;
right: -4px;
top: -30px;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 6px;
box-shadow: inset 0 0 0 2px light-dark(#50535b, #c6c8cd);
color: light-dark(#50535b, #c6c8cd);
font-family: Georgia, 'Times New Roman', serif;
font-size: 10px;
letter-spacing: 0.08em;
line-height: 1.1;
text-align: center;
opacity: 0;
transform: rotate(-10deg);
}
.field-haiku[data-done] .field-haiku__seal { animation: field-haiku-seal 0.45s cubic-bezier(0.4, 0, 0.4, 1) both; }
@media (prefers-reduced-motion: reduce) {
.field-haiku__seal { animation: none !important; }
.field-haiku[data-done] .field-haiku__seal { opacity: 1; }
}function initFieldHaiku(root) {
var TARGETS = [5, 7, 5];
var SR = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)';
var rows = root.querySelectorAll('.field-haiku__row');
var seal = root.querySelector('.field-haiku__seal');
// An English syllable estimate: vowel groups, less a silent ending. Good enough to count beads by.
function syllables(text) {
return text
.toLowerCase()
.split(/[^a-z']+/)
.filter(Boolean)
.reduce(function (n, w) {
if (w.length <= 3) return n + 1;
var core = w.replace(/(?:[^laeiouy]es|[^laeiouy]ed|[^laeiouy]e)$/, '').replace(/^y/, '');
return n + Math.max(1, (core.match(/[aeiouy]{1,2}/g) || []).length);
}, 0);
}
function update() {
var done = true;
rows.forEach(function (row, i) {
var c = syllables(row.querySelector('input').value);
var target = TARGETS[i];
if (c !== target) done = false;
row.toggleAttribute('data-over', c > target);
row.querySelectorAll('.field-haiku__bead').forEach(function (bead, k) { bead.toggleAttribute('data-on', k < c); });
row.querySelector('.field-haiku__over').innerHTML = c > target ? '+' + (c - target) : '<span style="' + SR + '">' + c + ' of ' + target + ' syllables</span>';
});
if (done !== root.hasAttribute('data-done')) {
root.toggleAttribute('data-done', done);
seal.innerHTML = done ? 'HAI<br>KU' : '';
}
}
root.addEventListener('input', update);
return function () { root.removeEventListener('input', update); };
}
document.querySelectorAll('.field-haiku').forEach(initFieldHaiku);Goal: Create a "Haiku" input.
Visual behavior: A haiku field in three lines with syllable beads beside each one that fill as you write five, seven and five; go over and the extra shows, and get all three right for a seal to stamp down.
Technique:
- Each line's syllables are estimated from vowel groups, less a silent ending (good enough to count beads by), and its beads light with data-on up to the count; past the target the row is marked data-over and shows the excess.
- When every line hits its target the group is marked data-done and a seal stamps down with a rotate and scale keyframe.
Accessibility: Three real inputs in a role="group" named "Write a haiku", each with an aria-label giving its line and target, and an aria-describedby count read out as "3 of 5 syllables" or the excess. The seal is aria-live="polite". Under prefers-reduced-motion the seal appears without stamping.
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 "Haiku" input.
Visual behavior: A haiku field in three lines with syllable beads beside each one that fill as you write five, seven and five; go over and the extra shows, and get all three right for a seal to stamp down.
Technique:
- Each line's syllables are estimated from vowel groups, less a silent ending (good enough to count beads by), and its beads light with data-on up to the count; past the target the row is marked data-over and shows the excess.
- When every line hits its target the group is marked data-done and a seal stamps down with a rotate and scale keyframe.
Accessibility: Three real inputs in a role="group" named "Write a haiku", each with an aria-label giving its line and target, and an aria-describedby count read out as "3 of 5 syllables" or the excess. The seal is aria-live="polite". Under prefers-reduced-motion the seal appears without stamping.
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 haiku field in three lines with syllable beads beside each one that fill as you write five, seven and five; go over and the extra shows, and get all three right for a seal to stamp down.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, fill
- Techniques
- keyframes, css transition, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Three real inputs in a role="group" named "Write a haiku", each with an aria-label giving its line and target, and an aria-describedby count read out as "3 of 5 syllables" or the excess. The seal is aria-live="polite". Under prefers-reduced-motion the seal appears without stamping.