02바이트 값이 스스로 단위를 고르는 파일 크기 배지
입력한 바이트 값의 자릿수를 계산해 KB·MB·GB 가운데 알맞은 단위를 스스로 고르고, 그 값에 비례한 길이만큼 배지 안 막대를 채운다.
쓰임파일 업로드 폼·클라우드 저장소 용량 표시
pickUnitdigitsOffillRatio

<main class="stage">
<div class="fb is-demo" id="fb">
<label class="fb__cap" for="fbBytes">바이트 값</label>
<div class="fb__body">
<input class="fb__input" id="fbBytes" type="number" inputmode="numeric" min="0" step="1" value="483" aria-label="바이트 값을 입력하면 자릿수를 계산해 단위를 고르고 막대를 채웁니다" aria-describedby="fbReadout">
<div class="fb__badge" aria-hidden="true">
<div class="fb__bar" id="fbBar"></div>
<span class="fb__unit" id="fbUnit">B</span>
</div>
</div>
<p class="fb__readout" id="fbReadout" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(자릿수->단위 선택, 채움 비율)은 index.html 의 pickUnit()/fillRatio() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-yellow);
.fb {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-2;
width: min(240px, 84vw);
background: $subject-ink;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
}
.fb__cap {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
.fb__body {
display: flex;
align-items: center;
gap: $sp-2;
}
.fb__input {
width: 64px;
flex: 0 0 auto;
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-2;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 14px;
text-align: center;
}
.fb__input:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.fb__badge {
position: relative;
flex: 1 1 auto;
height: 22px;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
overflow: hidden;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 $sp-2;
}
.fb__bar {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: linear-gradient(to right, $subject-blue, $stage-orange);
transform-origin: left center;
transform: scaleX(0);
will-change: transform;
transition: box-shadow $duration $easing;
}
.fb__unit {
position: relative;
color: $subject-cream;
font-size: 11px;
font-weight: 700;
}
.fb__readout {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.fb { padding: $sp-3; gap: $sp-1; width: min(220px, 84vw); }
.fb__cap { font-size: 10px; }
.fb__body { gap: $sp-1; }
.fb__input { width: 52px; padding: $sp-1; font-size: 12px; }
.fb__badge { height: 18px; padding: 0 $sp-1; }
.fb__unit { font-size: 10px; }
.fb__readout { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.fb__bar { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fb {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: min(240px, 84vw);
background: #17141a;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fb__cap {
margin: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
.fb__body {
display: flex;
align-items: center;
gap: 8px;
}
.fb__input {
width: 64px;
flex: 0 0 auto;
border: 0;
border-radius: 12px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 14px;
text-align: center;
}
.fb__input:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.fb__badge {
position: relative;
flex: 1 1 auto;
height: 22px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
overflow: hidden;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 8px;
}
.fb__bar {
position: absolute;
inset: 0;
border-radius: 999px;
background: linear-gradient(to right, #2f6df6, #ff4d1f);
transform-origin: left center;
transform: scaleX(0);
will-change: transform;
transition: box-shadow 2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fb__unit {
position: relative;
color: #fff7e6;
font-size: 11px;
font-weight: 700;
}
.fb__readout {
margin: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.fb {
padding: 12px;
gap: 4px;
width: min(220px, 84vw);
}
.fb__cap {
font-size: 10px;
}
.fb__body {
gap: 4px;
}
.fb__input {
width: 52px;
padding: 4px;
font-size: 12px;
}
.fb__badge {
height: 18px;
padding: 0 4px;
}
.fb__unit {
font-size: 10px;
}
.fb__readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fb__bar {
transition: none;
}
}(function () {
var root = document.getElementById('fb');
var input = document.getElementById('fbBytes');
var bar = document.getElementById('fbBar');
var unitEl = document.getElementById('fbUnit');
var readout = document.getElementById('fbReadout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
// 계산 한 겹 — 자릿수로 단위를 고르고(digitsOf/pickUnit), 그 단위 안에서 채움 비율(fillRatio)을 구한다
function digitsOf(n) {
n = Math.floor(Math.abs(n));
return n === 0 ? 1 : String(n).length;
}
function pickUnit(bytes) {
var d = digitsOf(bytes);
if (d <= 3) return { name: 'B', pow: 0 };
if (d <= 6) return { name: 'KB', pow: 1 };
if (d <= 9) return { name: 'MB', pow: 2 };
return { name: 'GB', pow: 3 };
}
function fillRatio(bytes, unit) {
var v = bytes / Math.pow(1024, unit.pow);
return clamp01(v / 1024);
}
var SENT_PREFIX = '입력한 ', SENT_MID1 = ' 바이트는 ', SENT_MID2 = ' 단위 기준 ', SENT_SUFFIX = ' 만큼입니다';
var targetR = 0, renderedR = 0;
var LERP_RATE = 0.16; // 변형 02: 낮추면 막대가 값 변화를 더 천천히 뒤따라간다
function readValue() {
var n = parseFloat(input.value);
return isNaN(n) || n < 0 ? 0 : n;
}
function applyValue(bytes) {
var unit = pickUnit(bytes);
var valueInUnit = bytes / Math.pow(1024, unit.pow);
targetR = fillRatio(bytes, unit);
unitEl.textContent = unit.name;
readout.textContent = SENT_PREFIX + bytes + SENT_MID1 + unit.name + SENT_MID2 + valueInUnit.toFixed(2) + SENT_SUFFIX;
if (reduce) { renderedR = targetR; paint(); }
}
function paint() {
bar.style.transform = 'scaleX(' + renderedR.toFixed(4) + ')';
}
input.addEventListener('input', function () { human(); applyValue(readValue()); });
input.addEventListener('change', function () { human(); applyValue(readValue()); });
var DEMO_VALUES = [7, 320, 4821, 88320, 2748000, 91000000, 1200000000, 15000000000];
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; input.value = DEMO_VALUES[idx]; applyValue(DEMO_VALUES[idx]); }
}
requestAnimationFrame(driveTick);
}
function frame() { renderedR += (targetR - renderedR) * LERP_RATE; paint(); requestAnimationFrame(frame); }
applyValue(readValue());
if (!reduce) { requestAnimationFrame(driveTick); requestAnimationFrame(frame); }
})();import { useEffect, useRef } from "react";
import styles from "./FilesizeUnitBadge.module.scss";
export default function FilesizeUnitBadge() {
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const barRef = useRef<HTMLDivElement | null>(null);
const unitRef = useRef<HTMLSpanElement | null>(null);
const readoutRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const bar = barRef.current;
const unitEl = unitRef.current;
const readout = readoutRef.current;
if (!root || !input || !bar || !unitEl || !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;
}
}
function clamp01(v: number) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
type Unit = { name: string; pow: number };
function digitsOf(n: number) {
n = Math.floor(Math.abs(n));
return n === 0 ? 1 : String(n).length;
}
function pickUnit(bytes: number): Unit {
const d = digitsOf(bytes);
if (d <= 3) return { name: "B", pow: 0 };
if (d <= 6) return { name: "KB", pow: 1 };
if (d <= 9) return { name: "MB", pow: 2 };
return { name: "GB", pow: 3 };
}
function fillRatio(bytes: number, unit: Unit) {
const v = bytes / Math.pow(1024, unit.pow);
return clamp01(v / 1024);
}
const SENT_PREFIX = "입력한 ";
const SENT_MID1 = " 바이트는 ";
const SENT_MID2 = " 단위 기준 ";
const SENT_SUFFIX = " 만큼입니다";
let targetR = 0;
let renderedR = 0;
const LERP_RATE = 0.16;
function readValue() {
const n = parseFloat(input!.value);
return isNaN(n) || n < 0 ? 0 : n;
}
function applyValue(bytes: number) {
const unit = pickUnit(bytes);
const valueInUnit = bytes / Math.pow(1024, unit.pow);
targetR = fillRatio(bytes, unit);
unitEl!.textContent = unit.name;
readout!.textContent =
SENT_PREFIX + bytes + SENT_MID1 + unit.name + SENT_MID2 + valueInUnit.toFixed(2) + SENT_SUFFIX;
if (reduce) {
renderedR = targetR;
paint();
}
}
function paint() {
bar!.style.transform = "scaleX(" + renderedR.toFixed(4) + ")";
}
function onInput() {
human();
applyValue(readValue());
}
input.addEventListener("input", onInput);
input.addEventListener("change", onInput);
const DEMO_VALUES = [7, 320, 4821, 88320, 2748000, 91000000, 1200000000, 15000000000];
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;
input!.value = String(DEMO_VALUES[idx]);
applyValue(DEMO_VALUES[idx]);
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame() {
renderedR += (targetR - renderedR) * LERP_RATE;
paint();
rafFrame = requestAnimationFrame(frame);
}
applyValue(readValue());
if (!reduce) {
rafDrive = requestAnimationFrame(driveTick);
rafFrame = requestAnimationFrame(frame);
}
return () => {
input.removeEventListener("input", onInput);
input.removeEventListener("change", onInput);
if (rafDrive) cancelAnimationFrame(rafDrive);
if (rafFrame) cancelAnimationFrame(rafFrame);
};
}, []);
return (
<div ref={rootRef} className={`${styles.fb} is-demo`}>
<label className={styles.cap} htmlFor="fbBytesReact">
바이트 값
</label>
<div className={styles.body}>
<input
ref={inputRef}
className={styles.input}
id="fbBytesReact"
type="number"
inputMode="numeric"
min={0}
step={1}
defaultValue={483}
aria-label="바이트 값을 입력하면 자릿수를 계산해 단위를 고르고 막대를 채웁니다"
aria-describedby="fbReadoutReact"
/>
<div className={styles.badge} aria-hidden="true">
<div ref={barRef} className={styles.bar} />
<span ref={unitRef} className={styles.unit}>B</span>
</div>
</div>
<p ref={readoutRef} className={styles.readout} id="fbReadoutReact" role="status" aria-live="polite" />
</div>
);
}02 바이트 값이 스스로 단위를 고르는 파일 크기 배지 — 측정 누적면적 13.481% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 계산기 디자인 9종 — 인풋 박스 디자인이 값을 그리는 방식
이 데모가 속한 글
계산기 디자인 9종 — 인풋 박스 디자인이 값을 그리는 방식css 계산기 디자인 아홉 가지 가운데 하나는 입력한 텍스트 값을 격자 값으로 바꿔 캔버스에 QR 모양을 직접 그리고, 하나는 생년월일 값으로 남은 날수를 계산해 링 게이지를 채우며, 하나는 입력한 시각 값 하나로 이 글의 데모 전부 보기


