09Unit Scale Where a Length Value Pushes Two Rulers Together
A length value typed on one ruler is computed into a position on a second ruler using a different unit, and the two rulers move together along a shared axis for comparison.
Best forFurniture and interior shopping-site dimension forms
cmToInchinchToCmapplyFromCm

<main class="stage">
<div class="lu is-demo" id="lu">
<div class="lu__fields">
<label class="lu__field">
<span class="lu__cap">Centimeters</span>
<input class="lu__cm" id="luCm" type="number" inputmode="decimal" step="0.5" min="0" max="60" value="30" aria-label="Entering a centimeter value also calculates inches and moves the position on both rulers" aria-describedby="luReadout">
</label>
<label class="lu__field">
<span class="lu__cap">Inches</span>
<input class="lu__in" id="luIn" type="number" inputmode="decimal" step="0.1" min="0" max="24" value="11.8" aria-label="Entering an inch value also calculates centimeters and moves the position on both rulers" aria-describedby="luReadout">
</label>
</div>
<div class="lu__rulers" aria-hidden="true">
<div class="lu__ruler lu__ruler--cm">
<div class="lu__track"></div>
<div class="lu__marker" id="luMarkCm"></div>
</div>
<div class="lu__ruler lu__ruler--in">
<div class="lu__track"></div>
<div class="lu__marker" id="luMarkIn"></div>
</div>
</div>
<p class="lu__readout" id="luReadout" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(cm<->inch 상호변환, 두 눈금 위치 산출)은 index.html 의 cmToInch()/inchToCm() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.lu {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-2;
width: min(240px, 84vw);
background: $subject-blue-deep;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
}
.lu__fields {
display: flex;
gap: $sp-2;
}
.lu__field {
display: flex;
flex-direction: column;
gap: $sp-1;
flex: 1 1 0;
}
.lu__cap {
color: $color;
font-size: 11px;
font-weight: 500;
}
.lu__cm,
.lu__in {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-2;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
text-align: center;
}
.lu__cm:focus-visible,
.lu__in:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.lu__rulers {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lu__ruler {
position: relative;
height: 14px;
}
.lu__track {
position: absolute;
inset: 0;
border-radius: $r-pill;
background:
repeating-linear-gradient(
to right,
rgba($subject-cream, .5) 0 1px,
transparent 1px 12%
),
rgba($subject-cream, .16);
}
.lu__marker {
position: absolute;
top: 50%;
left: 0;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: $subject-cream;
box-shadow: $shadow-press;
transition: box-shadow $duration $easing;
will-change: transform;
}
.lu__readout {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.lu { padding: $sp-3; gap: $sp-1; width: min(220px, 84vw); }
.lu__fields { gap: $sp-1; }
.lu__cap { font-size: 10px; }
.lu__cm, .lu__in { font-size: 11px; }
.lu__rulers { gap: $sp-1; }
.lu__ruler { height: 12px; }
.lu__marker { width: 10px; height: 10px; margin-top: -5px; }
.lu__readout { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.lu__marker { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lu {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: min(240px, 84vw);
background: #0a4fe8;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lu__fields {
display: flex;
gap: 8px;
}
.lu__field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 0;
}
.lu__cap {
color: #fff7e6;
font-size: 11px;
font-weight: 500;
}
.lu__cm,
.lu__in {
width: 100%;
border: 0;
border-radius: 12px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
text-align: center;
}
.lu__cm:focus-visible,
.lu__in:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.lu__rulers {
display: flex;
flex-direction: column;
gap: 8px;
}
.lu__ruler {
position: relative;
height: 14px;
}
.lu__track {
position: absolute;
inset: 0;
border-radius: 999px;
background: repeating-linear-gradient(to right, rgba(255, 247, 230, 0.5) 0 1px, transparent 1px 12%), rgba(255, 247, 230, 0.16);
}
.lu__marker {
position: absolute;
top: 50%;
left: 0;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: box-shadow 2s cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform;
}
.lu__readout {
margin: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.lu {
padding: 12px;
gap: 4px;
width: min(220px, 84vw);
}
.lu__fields {
gap: 4px;
}
.lu__cap {
font-size: 10px;
}
.lu__cm, .lu__in {
font-size: 11px;
}
.lu__rulers {
gap: 4px;
}
.lu__ruler {
height: 12px;
}
.lu__marker {
width: 10px;
height: 10px;
margin-top: -5px;
}
.lu__readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.lu__marker {
transition: none;
}
}(function () {
var root = document.getElementById('lu');
var cmInput = document.getElementById('luCm');
var inInput = document.getElementById('luIn');
var markCm = document.getElementById('luMarkCm');
var markIn = document.getElementById('luMarkIn');
var readout = document.getElementById('luReadout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var CM_MAX = 60, IN_MAX = 24;
// 계산 한 겹 — cm<->inch 상호 변환, 각 눈금 위 위치(0~1)
function cmToInch(cm) { return cm / 2.54; }
function inchToCm(inch) { return inch * 2.54; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
var SENT_PREFIX = 'Centimeters ', SENT_MID = ' convert to inches ', SENT_SUFFIX = '.';
var targetCm = 0, renderedCm = 0, targetIn = 0, renderedIn = 0;
var LERP_RATE = 0.15;
function readSentence(cm, inch) {
readout.textContent = SENT_PREFIX + cm.toFixed(1) + SENT_MID + inch.toFixed(2) + SENT_SUFFIX;
}
function applyFromCm() {
var cm = parseFloat(cmInput.value); if (isNaN(cm) || cm < 0) cm = 0;
var inch = cmToInch(cm);
inInput.value = inch.toFixed(2);
targetCm = clamp01(cm / CM_MAX);
targetIn = clamp01(inch / IN_MAX);
readSentence(cm, inch);
if (reduce) { renderedCm = targetCm; renderedIn = targetIn; paint(); }
}
function applyFromIn() {
var inch = parseFloat(inInput.value); if (isNaN(inch) || inch < 0) inch = 0;
var cm = inchToCm(inch);
cmInput.value = cm.toFixed(1);
targetCm = clamp01(cm / CM_MAX);
targetIn = clamp01(inch / IN_MAX);
readSentence(cm, inch);
if (reduce) { renderedCm = targetCm; renderedIn = targetIn; paint(); }
}
function paint() {
var trackCmW = markCm.parentElement.clientWidth - markCm.offsetWidth;
var trackInW = markIn.parentElement.clientWidth - markIn.offsetWidth;
markCm.style.transform = 'translateX(' + (renderedCm * trackCmW).toFixed(1) + 'px)';
markIn.style.transform = 'translateX(' + (renderedIn * trackInW).toFixed(1) + 'px)';
}
function onCmInput() { human(); applyFromCm(); }
function onInInput() { human(); applyFromIn(); }
cmInput.addEventListener('input', onCmInput);
cmInput.addEventListener('change', onCmInput);
inInput.addEventListener('input', onInInput);
inInput.addEventListener('change', onInInput);
var DEMO_VALUES = [8, 15, 30, 45.5, 52, 22.5];
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0, lastIdx = -1;
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var idx = Math.floor(frac * DEMO_VALUES.length);
if (idx !== lastIdx) {
lastIdx = idx;
cmInput.value = DEMO_VALUES[idx];
applyFromCm();
}
}
requestAnimationFrame(driveTick);
}
function frame() {
renderedCm += (targetCm - renderedCm) * LERP_RATE;
renderedIn += (targetIn - renderedIn) * LERP_RATE;
paint();
requestAnimationFrame(frame);
}
window.addEventListener('resize', paint);
applyFromCm();
if (!reduce) {
requestAnimationFrame(driveTick);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./LengthUnitScale.module.scss";
export default function LengthUnitScale() {
const rootRef = useRef<HTMLDivElement | null>(null);
const cmRef = useRef<HTMLInputElement | null>(null);
const inRef = useRef<HTMLInputElement | null>(null);
const markCmRef = useRef<HTMLDivElement | null>(null);
const markInRef = useRef<HTMLDivElement | null>(null);
const readoutRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cmInput = cmRef.current;
const inInput = inRef.current;
const markCm = markCmRef.current;
const markIn = markInRef.current;
const readout = readoutRef.current;
if (!root || !cmInput || !inInput || !markCm || !markIn || !readout) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
const CM_MAX = 60;
const IN_MAX = 24;
function cmToInch(cm: number) {
return cm / 2.54;
}
function inchToCm(inch: number) {
return inch * 2.54;
}
function clamp01(v: number) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
const SENT_PREFIX = "센티미터 ";
const SENT_MID = "는 인치 ";
const SENT_SUFFIX = "입니다";
let targetCm = 0;
let renderedCm = 0;
let targetIn = 0;
let renderedIn = 0;
const LERP_RATE = 0.15;
function readSentence(cm: number, inch: number) {
readout!.textContent = SENT_PREFIX + cm.toFixed(1) + SENT_MID + inch.toFixed(2) + SENT_SUFFIX;
}
function applyFromCm() {
let cm = parseFloat(cmInput!.value); if (isNaN(cm) || cm < 0) cm = 0;
const inch = cmToInch(cm);
inInput!.value = inch.toFixed(2);
targetCm = clamp01(cm / CM_MAX);
targetIn = clamp01(inch / IN_MAX);
readSentence(cm, inch);
if (reduce) {
renderedCm = targetCm;
renderedIn = targetIn;
paint();
}
}
function applyFromIn() {
let inch = parseFloat(inInput!.value); if (isNaN(inch) || inch < 0) inch = 0;
const cm = inchToCm(inch);
cmInput!.value = cm.toFixed(1);
targetCm = clamp01(cm / CM_MAX);
targetIn = clamp01(inch / IN_MAX);
readSentence(cm, inch);
if (reduce) {
renderedCm = targetCm;
renderedIn = targetIn;
paint();
}
}
function paint() {
const trackCmW = (markCm!.parentElement as HTMLDivElement).clientWidth - markCm!.offsetWidth;
const trackInW = (markIn!.parentElement as HTMLDivElement).clientWidth - markIn!.offsetWidth;
markCm!.style.transform = "translateX(" + (renderedCm * trackCmW).toFixed(1) + "px)";
markIn!.style.transform = "translateX(" + (renderedIn * trackInW).toFixed(1) + "px)";
}
function onCmInput() {
human();
applyFromCm();
}
function onInInput() {
human();
applyFromIn();
}
cmInput.addEventListener("input", onCmInput);
cmInput.addEventListener("change", onCmInput);
inInput.addEventListener("input", onInInput);
inInput.addEventListener("change", onInInput);
const DEMO_VALUES = [8, 15, 30, 45.5, 52, 22.5];
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
let lastIdx = -1;
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const idx = Math.floor(frac * DEMO_VALUES.length);
if (idx !== lastIdx) {
lastIdx = idx;
cmInput!.value = String(DEMO_VALUES[idx]);
applyFromCm();
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame() {
renderedCm += (targetCm - renderedCm) * LERP_RATE;
renderedIn += (targetIn - renderedIn) * LERP_RATE;
paint();
rafFrame = requestAnimationFrame(frame);
}
window.addEventListener("resize", paint);
applyFromCm();
if (!reduce) {
rafDrive = requestAnimationFrame(driveTick);
rafFrame = requestAnimationFrame(frame);
}
return () => {
cmInput.removeEventListener("input", onCmInput);
cmInput.removeEventListener("change", onCmInput);
inInput.removeEventListener("input", onInInput);
inInput.removeEventListener("change", onInInput);
window.removeEventListener("resize", paint);
if (rafDrive) cancelAnimationFrame(rafDrive);
if (rafFrame) cancelAnimationFrame(rafFrame);
};
}, []);
return (
<div ref={rootRef} className={`${styles.root} is-demo`}>
<div className={styles.fields}>
<label className={styles.field}>
<span className={styles.cap}>센티미터</span>
<input
ref={cmRef}
className={styles.cm}
type="number"
inputMode="decimal"
step={0.5}
min={0}
max={60}
defaultValue={30}
aria-label="센티미터 값을 입력하면 인치로 함께 계산해 두 눈금 위 위치를 옮깁니다"
aria-describedby="luReadoutReact"
/>
</label>
<label className={styles.field}>
<span className={styles.cap}>인치</span>
<input
ref={inRef}
className={styles.inch}
type="number"
inputMode="decimal"
step={0.1}
min={0}
max={24}
defaultValue={11.8}
aria-label="인치 값을 입력하면 센티미터로 함께 계산해 두 눈금 위 위치를 옮깁니다"
aria-describedby="luReadoutReact"
/>
</label>
</div>
<div className={styles.rulers} aria-hidden="true">
<div className={styles.ruler}>
<div className={styles.track} />
<div ref={markCmRef} className={styles.marker} />
</div>
<div className={styles.ruler}>
<div className={styles.track} />
<div ref={markInRef} className={styles.marker} />
</div>
</div>
<p ref={readoutRef} className={styles.readout} id="luReadoutReact" role="status" aria-live="polite" />
</div>
);
}09 Unit Scale Where a Length Value Pushes Two Rulers Together — Measured area 5.763% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Calculator Design: 9 Inputs, Unit Converter UI
The post this demo lives in
CSS Calculator Design: 9 Inputs, Unit Converter UIAmong nine css calculator design inputs, one draws a QR grid from a text value, one fills a ring gauge from a birthdate See all demos in this post
If you like this effect - similar demos
Inputs08 Live confirm-match diff — the position value where a confirmation input first diverges from the original value is found in real timePassword Strength Meter UI: 9 Calculation Patterns
Inputs06 Keyboard jump — arrow keys change the value by one unit, a modifier key by a larger unitNumber Stepper UI: 9 Interaction Patterns
Inputs05 Live validity value toggle — each time a digit value fills in, the whole number's validity value is recalculated and the display value changesCredit Card Input UI: 9 Patterns