Measure
import { useLayoutEffect, useRef, useState } from 'react';
const css = `
.field-measure { width: 256px; }
.field-measure__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-measure__sheet {
position: relative;
height: 96px;
border-radius: 8px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 12px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 12px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.field-measure__sheet:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-measure__input, .field-measure__ruler { font-family: inherit; font-size: 24px; font-weight: 600; font-kerning: none; letter-spacing: -0.01em; }
.field-measure__input { position: absolute; left: 0; top: 18px; width: 100%; height: 34px; padding: 0 14px; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); }
.field-measure__ruler { position: absolute; left: 14px; top: 0; visibility: hidden; white-space: pre; }
/* Baseline and cap-height guides. */
.field-measure__guide { position: absolute; left: 0; right: 0; height: 0; border-top: 1px dashed light-dark(#dbdde1, #2e3035); }
.field-measure__dim { position: absolute; left: 14px; top: 64px; height: 14px; transition: width 0.25s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-measure__dim::before { content: ''; position: absolute; left: 0; right: 0; top: 6px; border-top: 1px solid light-dark(#50535b, #c6c8cd); }
.field-measure__dim i { position: absolute; top: 0; width: 1px; height: 13px; background: light-dark(#50535b, #c6c8cd); }
.field-measure__dim i:first-child { left: 0; }
.field-measure__dim i:last-child { right: 0; }
.field-measure__tag {
position: absolute;
left: 50%;
top: -1px;
padding: 0 5px;
transform: translateX(-50%);
background: light-dark(#fbfcfd, #121417);
color: light-dark(#42454c, #e5e6e9);
font: 600 10px/14px ui-monospace, SFMono-Regular, Menlo, monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.field-measure__meta { display: flex; justify-content: space-between; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (prefers-reduced-motion: reduce) { .field-measure__dim { transition: none; } }
`;
export function MeasureInput() {
const ruler = useRef<HTMLSpanElement>(null);
const [value, setValue] = useState('Blueprint');
const [w, setW] = useState(0);
const [shown, setShown] = useState(0);
useLayoutEffect(() => setW(ruler.current!.offsetWidth), [value]);
// The readout counts to the new width rather than jumping.
useLayoutEffect(() => {
let raf = 0;
const step = () => {
setShown((s) => {
const n = s + (w - s) * 0.3;
if (Math.abs(w - n) < 0.5) return w;
raf = requestAnimationFrame(step);
return n;
});
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [w]);
const width = Math.min(w, 228);
return (
<>
<style>
{css}
</style>
<div className="field-measure">
<label className="field-measure__label" htmlFor="field-measure-input">
Title
</label>
<div className="field-measure__sheet">
<span className="field-measure__guide" style={{ top: 26 }} aria-hidden="true" />
<span className="field-measure__guide" style={{ top: 45 }} aria-hidden="true" />
<span ref={ruler} className="field-measure__ruler" aria-hidden="true">
{value}
</span>
<input
id="field-measure-input"
className="field-measure__input"
value={value}
maxLength={20}
autoComplete="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
/>
<span className="field-measure__dim" style={{ width }} aria-hidden="true">
<i />
<span className="field-measure__tag">{Math.round(shown)} px</span>
<i />
</span>
</div>
<div className="field-measure__meta" aria-hidden="true">
<span>24 / 700 · {value.length} glyphs</span>
<span>cap 17 px</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldMeasure(el) on the mounted element instead.
<div class="field-measure">
<label class="field-measure__label" for="field-measure-input">Title</label>
<div class="field-measure__sheet">
<span class="field-measure__guide" style="top:26px" aria-hidden="true"></span>
<span class="field-measure__guide" style="top:45px" aria-hidden="true"></span>
<span class="field-measure__ruler" aria-hidden="true">Blueprint</span>
<input id="field-measure-input" class="field-measure__input" maxLength="20" autoComplete="off" spellCheck="false" value="Blueprint"/>
<span class="field-measure__dim" style="width:0" aria-hidden="true">
<i></i>
<span class="field-measure__tag">0 px</span>
<i></i>
</span>
</div>
<div class="field-measure__meta" aria-hidden="true">
<span>24 / 700 · 9 glyphs</span>
<span>cap 17 px</span>
</div>
</div>.field-measure { width: 256px; }
.field-measure__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-measure__sheet {
position: relative;
height: 96px;
border-radius: 8px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 12px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 12px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.field-measure__sheet:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-measure__input, .field-measure__ruler { font-family: inherit; font-size: 24px; font-weight: 600; font-kerning: none; letter-spacing: -0.01em; }
.field-measure__input { position: absolute; left: 0; top: 18px; width: 100%; height: 34px; padding: 0 14px; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); }
.field-measure__ruler { position: absolute; left: 14px; top: 0; visibility: hidden; white-space: pre; }
/* Baseline and cap-height guides. */
.field-measure__guide { position: absolute; left: 0; right: 0; height: 0; border-top: 1px dashed light-dark(#dbdde1, #2e3035); }
.field-measure__dim { position: absolute; left: 14px; top: 64px; height: 14px; transition: width 0.25s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-measure__dim::before { content: ''; position: absolute; left: 0; right: 0; top: 6px; border-top: 1px solid light-dark(#50535b, #c6c8cd); }
.field-measure__dim i { position: absolute; top: 0; width: 1px; height: 13px; background: light-dark(#50535b, #c6c8cd); }
.field-measure__dim i:first-child { left: 0; }
.field-measure__dim i:last-child { right: 0; }
.field-measure__tag {
position: absolute;
left: 50%;
top: -1px;
padding: 0 5px;
transform: translateX(-50%);
background: light-dark(#fbfcfd, #121417);
color: light-dark(#42454c, #e5e6e9);
font: 600 10px/14px ui-monospace, SFMono-Regular, Menlo, monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.field-measure__meta { display: flex; justify-content: space-between; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (prefers-reduced-motion: reduce) { .field-measure__dim { transition: none; } }function initFieldMeasure(root) {
var input = root.querySelector('.field-measure__input');
var ruler = root.querySelector('.field-measure__ruler');
var dim = root.querySelector('.field-measure__dim');
var tag = root.querySelector('.field-measure__tag');
var meta = root.querySelector('.field-measure__meta span');
if (!input || !ruler || !dim || !tag || !meta) return;
var shown = 0;
var raf = 0;
function update() {
ruler.textContent = input.value;
var w = ruler.offsetWidth;
dim.style.width = Math.min(w, 228) + 'px';
meta.textContent = '24 / 700 · ' + input.value.length + ' glyphs';
// The readout counts to the new width rather than jumping.
cancelAnimationFrame(raf);
function step() {
shown += (w - shown) * 0.3;
if (Math.abs(w - shown) < 0.5) shown = w;
tag.textContent = Math.round(shown) + ' px';
if (shown !== w) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', update);
update();
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.field-measure').forEach(initFieldMeasure);Goal: Create a "Measure" input.
Visual behavior: A blueprint title field: your text sits on a grid between baseline and cap-height guides, and a dimension line with end ticks measures its real width in pixels, the number counting as it changes.
Technique:
- A hidden span with the same font holds the text; its offsetWidth is the measurement.
- The dimension line's width transitions to it, and the label eases toward the number on requestAnimationFrame.
- The sheet is two 1px line gradients on a 12px grid; dashed guides mark baseline and cap height.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The drawing is aria-hidden. Under prefers-reduced-motion the line resizes 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 "Measure" input.
Visual behavior: A blueprint title field: your text sits on a grid between baseline and cap-height guides, and a dimension line with end ticks measures its real width in pixels, the number counting as it changes.
Technique:
- A hidden span with the same font holds the text; its offsetWidth is the measurement.
- The dimension line's width transitions to it, and the label eases toward the number on requestAnimationFrame.
- The sheet is two 1px line gradients on a 12px grid; dashed guides mark baseline and cap height.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The drawing is aria-hidden. Under prefers-reduced-motion the line resizes 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 blueprint title field: your text sits on a grid between baseline and cap-height guides, and a dimension line with end ticks measures its real width in pixels, the number counting as it changes.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- focus, state change
- Techniques
- css transition, raf, gradient
- 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 drawing is aria-hidden. Under prefers-reduced-motion the line resizes at once.