08Size computation — converting bytes into human units and comparing against a cap
How many times File.size divides by 1024 (2 to the 10th, Math.pow(2,10)) picks the unit -- KB, MB, GB -- and flooring to one decimal renders it; the ratio to the cap drives a ring gauge that shifts color as a file nears the limit, and an over-limit file gets its excess spelled out in MB.
Best forEmail attachments, cloud upload cards
File.sizeMath.powtransition

<main class="stage">
<div class="sl" id="sl">
<p class="sl__hint" id="slHint">File.size is divided by 1024 repeatedly to pick a unit, cut at the second decimal</p>
<div class="sl__zone" id="slZone" role="button" tabindex="0" aria-describedby="slHint" aria-label="Size-limit dropzone">
<input type="file" id="slInput" class="sl__file" aria-label="Choose file">
<svg class="sl__ic" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 152v24a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8v-22.87C24 95.65 70.15 48.2 127.63 48A104 104 0 0 1 232 152" opacity=".2"/><path d="M207.06 72.67A111.24 111.24 0 0 0 128 40h-.4C66.07 40.21 16 91 16 153.13V176a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16v-24a111.25 111.25 0 0 0-32.94-79.33M224 176H119.71l54.76-75.3a8 8 0 0 0-12.94-9.42L99.92 176H32v-22.87c0-3.08.15-6.12.43-9.13H56a8 8 0 0 0 0-16H35.27c10.32-38.86 44-68.24 84.73-71.66V80a8 8 0 0 0 16 0V56.33A96.14 96.14 0 0 1 221 128h-21a8 8 0 0 0 0 16h23.67c.21 2.65.33 5.31.33 8Z"/></g></svg>
</div>
<div class="sl__card">
<div class="sl__ring" id="slRing" role="img" aria-label="Ratio-to-limit gauge">
<svg class="sl__svg" viewBox="0 0 72 72" aria-hidden="true">
<circle class="sl__ring-bg" cx="36" cy="36" r="30"></circle>
<circle class="sl__ring-val" id="slRingVal" cx="36" cy="36" r="30"></circle>
</svg>
<span class="sl__ring-num" id="slRingNum">0%</span>
</div>
<div class="sl__info">
<span class="sl__name" id="slName">—</span>
<span class="sl__size" id="slSize">0 B</span>
<span class="sl__limit" id="slLimit">limit 5.00 MB</span>
<span class="sl__verdict" id="slVerdict" aria-live="polite">Drop a file to see the verdict</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 600ms; // 링 게이지 rAF 보간에 대응하는 트랜지션
$easing: $ease-out;
$color: $subject-blue-deep;
// 파스텔 강조(채도 .34 · 명도 +.11)
$p-sky: #d6e1eb;
$p-warn: #ebe0d6;
@include stage($stage-orange);
.sl {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.sl__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
color: $subject-cream;
}
.sl__zone {
position: relative;
display: grid;
place-items: center;
min-height: 48px;
padding: $sp-2 $sp-3;
border: 2px dashed rgba($subject-cream, .72);
border-radius: $r-card;
cursor: pointer;
overflow: hidden;
transition: border-color $dur-quick $ease-out, background $dur-quick $ease-out;
}
.sl__zone.is-over {
border-color: $p-sky;
background: rgba(159, 201, 236, .16);
}
.sl__file {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.sl__ic {
width: 24px;
height: 24px;
color: $subject-cream;
transition: transform $dur-quick $ease-pop;
}
.sl__zone.is-over .sl__ic {
transform: scale(1.15);
}
.sl__card {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-card;
background: rgba($subject-cream, .1);
}
.sl__ring {
position: relative;
flex: 0 0 auto;
width: 72px;
height: 72px;
display: grid;
place-items: center;
}
.sl__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.sl__ring-bg {
fill: none;
stroke: rgba($subject-cream, .18);
stroke-width: 7;
}
.sl__ring-val {
fill: none;
stroke: $p-sky;
stroke-width: 7;
stroke-linecap: round;
stroke-dasharray: 188.5;
stroke-dashoffset: 188.5;
transition: stroke $duration $easing;
}
.sl.is-near-limit .sl__ring-val {
stroke: $subject-mint;
}
.sl.is-over-limit .sl__ring-val {
stroke: $p-warn;
}
.sl__ring-num {
font-weight: 700;
font-size: 13px;
color: $subject-cream;
}
.sl__info {
display: flex;
flex-direction: column;
gap: $sp-1;
min-width: 0;
}
.sl__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 700;
font-size: 12px;
color: $subject-cream;
}
.sl__size {
font-weight: 700;
font-size: 15px;
color: $p-sky;
}
.sl.is-over-limit .sl__size {
color: $p-warn;
}
.sl__limit {
font-weight: 400;
font-size: 10px;
color: rgba($subject-cream, .72);
}
.sl__verdict {
margin-top: $sp-1;
padding: $sp-1;
border-radius: $r-xs;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
color: $subject-cream;
background: rgba($subject-cream, .12);
}
@media (max-width: 340px) {
.sl { max-width: 260px; gap: $sp-1; }
.sl__hint { font-size: 9px; line-height: 1.25; }
.sl__zone { min-height: 32px; padding: $sp-1 $sp-2; }
.sl__ic { width: 20px; height: 20px; }
.sl__card { padding: $sp-1; gap: $sp-2; }
.sl__ring { width: 48px; height: 48px; }
.sl__info { gap: 0; }
.sl__name { font-size: 11px; }
.sl__size { font-size: 12px; }
.sl__limit { font-size: 9px; }
.sl__verdict { font-size: 8px; margin-top: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sl__zone, .sl__ic { transition: none; }
.sl__zone.is-over .sl__ic { transform: none; }
.sl__ring-val { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sl {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sl__hint {
margin: 0;
padding: 0 4px;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
color: #fff7e6;
}
.sl__zone {
position: relative;
display: grid;
place-items: center;
min-height: 48px;
padding: 8px 12px;
border: 2px dashed rgba(255, 247, 230, 0.72);
border-radius: 16px;
cursor: pointer;
overflow: hidden;
transition: border-color 220ms ease-out, background 220ms ease-out;
}
.sl__zone.is-over {
border-color: #d6e1eb;
background: rgba(159, 201, 236, 0.16);
}
.sl__file {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.sl__ic {
width: 24px;
height: 24px;
color: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sl__zone.is-over .sl__ic {
transform: scale(1.15);
}
.sl__card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.1);
}
.sl__ring {
position: relative;
flex: 0 0 auto;
width: 72px;
height: 72px;
display: grid;
place-items: center;
}
.sl__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.sl__ring-bg {
fill: none;
stroke: rgba(255, 247, 230, 0.18);
stroke-width: 7;
}
.sl__ring-val {
fill: none;
stroke: #d6e1eb;
stroke-width: 7;
stroke-linecap: round;
stroke-dasharray: 188.5;
stroke-dashoffset: 188.5;
transition: stroke 600ms ease-out;
}
.sl.is-near-limit .sl__ring-val {
stroke: #4cd4a6;
}
.sl.is-over-limit .sl__ring-val {
stroke: #ebe0d6;
}
.sl__ring-num {
font-weight: 700;
font-size: 13px;
color: #fff7e6;
}
.sl__info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.sl__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 700;
font-size: 12px;
color: #fff7e6;
}
.sl__size {
font-weight: 700;
font-size: 15px;
color: #d6e1eb;
}
.sl.is-over-limit .sl__size {
color: #ebe0d6;
}
.sl__limit {
font-weight: 400;
font-size: 10px;
color: rgba(255, 247, 230, 0.72);
}
.sl__verdict {
margin-top: 4px;
padding: 4px;
border-radius: 4px;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
color: #fff7e6;
background: rgba(255, 247, 230, 0.12);
}
@media (max-width: 340px) {
.sl {
max-width: 260px;
gap: 4px;
}
.sl__hint {
font-size: 9px;
line-height: 1.25;
}
.sl__zone {
min-height: 32px;
padding: 4px 8px;
}
.sl__ic {
width: 20px;
height: 20px;
}
.sl__card {
padding: 4px;
gap: 8px;
}
.sl__ring {
width: 48px;
height: 48px;
}
.sl__info {
gap: 0;
}
.sl__name {
font-size: 11px;
}
.sl__size {
font-size: 12px;
}
.sl__limit {
font-size: 9px;
}
.sl__verdict {
font-size: 8px;
margin-top: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.sl__zone, .sl__ic {
transition: none;
}
.sl__zone.is-over .sl__ic {
transform: none;
}
.sl__ring-val {
transition: none;
}
}// Size limit(08) — File.size(바이트) 를 Math.pow(2,10)=1024 로 거듭 나눠 사람 단위(B→KB→MB)로
// 고치고, DECIMALS(변형: 기본 첫째 자리 → 이 편은 둘째 자리) 자리에서 버린다. Limit(5MB) 대비
// 비율로 링 게이지를 채우고 100% 가 넘으면 초과분을 MB 로 따로 계산해 보여준다.
(function () {
var zone = document.getElementById('slZone');
var input = document.getElementById('slInput');
var ringVal = document.getElementById('slRingVal');
var ringNum = document.getElementById('slRingNum');
var nameEl = document.getElementById('slName');
var sizeEl = document.getElementById('slSize');
var limitEl = document.getElementById('slLimit');
var verdictEl = document.getElementById('slVerdict');
var card = document.getElementById('sl');
if (!zone || !input || !ringVal || !ringNum || !nameEl || !sizeEl || !limitEl || !verdictEl || !card) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var K = Math.pow(2, 10); // 1024 — 단위 계산의 밑
var DECIMALS = 2; // 변형: 기본 1(첫째 자리) → 이 편은 2(둘째 자리)
var LIMIT = 5 * 1024 * 1024; // 5 MB(바이트)
var FILES = [
{ name: 'intro.mp4', bytes: 1.8 * 1024 * 1024 },
{ name: 'clip-720.mov', bytes: 4.6 * 1024 * 1024 },
{ name: 'master-raw.mov', bytes: 8.3 * 1024 * 1024 },
{ name: 'sticker.png', bytes: 0.3 * 1024 * 1024 }
];
function fmt(bytes) {
var v = bytes, i = 0;
while (v >= K && i < 2) { v /= K; i++; } // 1024 거듭 나누기
var f = Math.pow(10, DECIMALS);
v = Math.floor(v * f) / f; // DECIMALS 자리에서 버림
return v.toFixed(DECIMALS) + ' ' + ['B', 'KB', 'MB'][i];
}
var shown = 0;
function show(f) {
var ratio = f.size / LIMIT; // Limit 대비 비율 — 링·숫자의 근원
var over = ratio > 1;
nameEl.textContent = f.name;
sizeEl.textContent = fmt(f.size);
limitEl.textContent = 'limit ' + fmt(LIMIT);
ringVal.style.strokeDashoffset = String(188.5 * (1 - Math.min(1, ratio)));
ringNum.textContent = Math.round(ratio * 100) + '%';
card.classList.toggle('is-over-limit', over);
card.classList.toggle('is-near-limit', !over && ratio >= 0.85);
if (over) {
verdictEl.textContent = 'over by +' + fmt(f.size - LIMIT) + ' — allowed up to ' + fmt(LIMIT) + ' max';
} else {
verdictEl.textContent = 'Within limit — remaining ' + fmt(LIMIT - f.size);
}
shown = ratio;
}
// 링 채움은 rAF 보간 — 숫자는 즉시 확정값, 링이 따라온다(계산 축: lerp)
var target = 0;
function tick() {
shown += (target - shown) * 0.18;
ringVal.style.strokeDashoffset = String(188.5 * (1 - Math.min(1, Math.max(0, shown))));
if (Math.abs(target - shown) > 0.002) requestAnimationFrame(tick);
}
function intake(f) {
show(f);
target = Math.min(1.2, f.size / LIMIT);
if (!reduce) requestAnimationFrame(tick);
}
function virtualFile(spec) {
var bytes = Math.round(spec.bytes);
try { return new File([new Blob([new Uint8Array(bytes)])], spec.name); }
catch (e) { return { name: spec.name, size: bytes, type: '' }; }
}
zone.addEventListener('dragover', function (e) { e.preventDefault(); zone.classList.add('is-over'); });
zone.addEventListener('dragleave', function () { zone.classList.remove('is-over'); });
zone.addEventListener('drop', function (e) {
e.preventDefault(); zone.classList.remove('is-over');
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) intake(f);
});
input.addEventListener('change', function () {
var f = input.files && input.files[0];
if (f) intake(f);
input.value = '';
});
if (reduce) {
show(virtualFile(FILES[1]));
} else {
var nextAt = 0, cur = -1;
(function loop(now) {
if (now >= nextAt) {
nextAt = now + 1300;
cur = (cur + 1) % FILES.length;
intake(virtualFile(FILES[cur]));
}
requestAnimationFrame(loop);
})(0);
}
})();// DropSizeLimit — vanilla 판(run/337/demo/08-drop-size-limit/index.html) 의 React 포트.
// ratio = size/LIMIT 로 초과 여부·링 게이지 stroke-dashoffset·경고 등급을 한 번에 계산하고,
// 표시되는 숫자는 즉시 갱신하되 링 시각만 rAF 지수보간(shown += (target-shown)*0.18)으로 늦게
// 따라오게 분리한다 — 이 디커플링이 계산 한 겹.
import { useEffect, useRef } from "react";
import styles from "./DropSizeLimit.module.scss";
export type DropSizeLimitProps = {
duration?: string;
easing?: string;
color?: string;
};
type Box<T> = { current: T };
const HINT = "size ÷ LIMIT 비율로 링 게이지를 계산하고, 링 시각만 지수보간으로 늦게 따라오게 합니다";
const ARIA_LABEL = "용량 제한 드롭존";
const K = Math.pow(2, 10);
const DECIMALS = 2; // 변형: 큐 변형 칩 08(1자리→2자리) 적용값
const LIMIT = 5 * 1024 * 1024;
const RING_C = 188.5; // 2πr, r=30
type FileSpec = { name: string; bytes: number };
const FILES: FileSpec[] = [
{ name: "intro.mp4", bytes: Math.round(1.8 * 1024 * 1024) },
{ name: "clip-720.mov", bytes: Math.round(4.6 * 1024 * 1024) },
{ name: "master-raw.mov", bytes: Math.round(8.3 * 1024 * 1024) },
{ name: "sticker.png", bytes: Math.round(0.3 * 1024 * 1024) }
];
function fmt(bytes: number): string {
const units = ["B", "KB", "MB"];
let i = 0, v = bytes;
while (v >= K && i < units.length - 1) { v /= K; i++; }
const f = Math.pow(10, DECIMALS);
const truncated = Math.floor(v * f) / f;
return truncated.toFixed(DECIMALS) + " " + units[i];
}
export default function DropSizeLimit({
duration = "600ms",
easing = "ease-out",
color = "#0a4fe8"
}: DropSizeLimitProps) {
const zoneRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
const ringValRef = useRef<SVGCircleElement | null>(null);
const ringNumRef = useRef<HTMLElement | null>(null);
const nameRef = useRef<HTMLElement | null>(null);
const sizeRef = useRef<HTMLElement | null>(null);
const limitRef = useRef<HTMLElement | null>(null);
const verdictRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
const tickRafRef = useRef(0) as Box<number>;
const shownRef = useRef(0) as Box<number>;
const targetRef = useRef(0) as Box<number>;
useEffect(() => {
const zone = zoneRef.current;
const input = inputRef.current;
const card = cardRef.current;
const ringVal = ringValRef.current;
const ringNum = ringNumRef.current;
const nameEl = nameRef.current;
const sizeEl = sizeRef.current;
const limitEl = limitRef.current;
const verdictEl = verdictRef.current;
if (!zone || !input || !card || !ringVal || !ringNum || !nameEl || !sizeEl || !limitEl || !verdictEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function tick(): void {
shownRef.current += (targetRef.current - shownRef.current) * 0.18;
const r = Math.max(0, Math.min(1, shownRef.current));
ringVal!.style.strokeDashoffset = String(RING_C * (1 - r));
ringNum!.textContent = Math.round(r * 100) + "%";
if (Math.abs(targetRef.current - shownRef.current) > 0.002) {
tickRafRef.current = requestAnimationFrame(tick);
}
}
function show(f: FileSpec): void {
const ratio = f.bytes / LIMIT;
const over = ratio > 1;
const near = ratio >= 0.85;
nameEl!.textContent = f.name;
sizeEl!.textContent = fmt(f.bytes);
limitEl!.textContent = fmt(LIMIT);
card!.classList.toggle(styles.cardOverLimit, over);
card!.classList.toggle(styles.cardNearLimit, !over && near);
verdictEl!.textContent = over
? "초과 +" + fmt(f.bytes - LIMIT) + " — 허용 " + fmt(LIMIT) + " 까지"
: "허용 범위 — 남음 " + fmt(LIMIT - f.bytes);
targetRef.current = Math.min(1, ratio);
if (reduce) {
shownRef.current = targetRef.current;
ringVal!.style.strokeDashoffset = String(RING_C * (1 - targetRef.current));
ringNum!.textContent = Math.round(targetRef.current * 100) + "%";
} else {
cancelAnimationFrame(tickRafRef.current);
tickRafRef.current = requestAnimationFrame(tick);
}
}
function intake(f: FileSpec): void {
show(f);
}
function virtualFile(spec: FileSpec): File {
try { return new File([new Blob([new Uint8Array(spec.bytes)])], spec.name); }
catch (e) { return new File([], spec.name); }
}
function onOver(e: DragEvent): void {
e.preventDefault();
zone!.classList.add(styles.zoneOver);
}
function onLeave(): void {
zone!.classList.remove(styles.zoneOver);
}
function onDrop(e: DragEvent): void {
e.preventDefault();
zone!.classList.remove(styles.zoneOver);
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) intake({ name: f.name, bytes: f.size });
}
function onChange(): void {
const f = input!.files && input!.files[0];
if (f) intake({ name: f.name, bytes: f.size });
input!.value = "";
}
zone.addEventListener("dragover", onOver as EventListener);
zone.addEventListener("dragleave", onLeave);
zone.addEventListener("drop", onDrop as EventListener);
input.addEventListener("change", onChange);
let cur = -1;
if (reduce) {
intake(FILES[0]);
} else {
let nextAt = 0;
const step = (now: number): void => {
if (now >= nextAt) {
nextAt = now + 1300;
cur = (cur + 1) % FILES.length;
intake(FILES[cur]);
}
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
cancelAnimationFrame(tickRafRef.current);
zone.removeEventListener("dragover", onOver as EventListener);
zone.removeEventListener("dragleave", onLeave);
zone.removeEventListener("drop", onDrop as EventListener);
input.removeEventListener("change", onChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.sl}>
<p className={styles.hint} id="slHintReact">{HINT}</p>
<div
className={styles.zone}
ref={zoneRef}
role="button"
tabIndex={0}
aria-describedby="slHintReact"
aria-label={ARIA_LABEL}
>
<input type="file" className={styles.file} ref={inputRef} aria-label="파일 선택" />
<svg className={styles.ic} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M232 152v24a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8v-22.87C24 95.65 70.15 48.2 127.63 48A104 104 0 0 1 232 152" opacity=".2" />
<path d="M207.06 72.67A111.24 111.24 0 0 0 128 40h-.4C66.07 40.21 16 91 16 153.13V176a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16v-24a111.25 111.25 0 0 0-32.94-79.33M224 176H119.71l54.76-75.3a8 8 0 0 0-12.94-9.42L99.92 176H32v-22.87c0-3.08.15-6.12.43-9.13H56a8 8 0 0 0 0-16H35.27c10.32-38.86 44-68.24 84.73-71.66V80a8 8 0 0 0 16 0V56.33A96.14 96.14 0 0 1 221 128h-21a8 8 0 0 0 0 16h23.67c.21 2.65.33 5.31.33 8Z" />
</g>
</svg>
</div>
<div className={styles.card} ref={cardRef} aria-live="polite">
<div className={styles.ring}>
<svg className={styles.svg} viewBox="0 0 72 72" aria-hidden="true">
<circle className={styles.ringBg} cx="36" cy="36" r="30" fill="none" strokeWidth="6" />
<circle
className={styles.ringValCircle}
ref={ringValRef}
cx="36" cy="36" r="30" fill="none" strokeWidth="6"
strokeDasharray="188.5"
strokeDashoffset="188.5"
style={{ stroke: color }}
/>
</svg>
<span className={styles.ringNum} ref={ringNumRef}>0%</span>
</div>
<div className={styles.info}>
<b className={styles.name} ref={nameRef}>intro.mp4</b>
<p className={styles.size} ref={sizeRef}>0.00 MB</p>
<p className={styles.limit} ref={limitRef}>허용 5.00 MB</p>
<p className={styles.verdict} ref={verdictRef}>허용 범위 — 남음 5.00 MB</p>
</div>
</div>
</div>
</main>
);
}08 Size computation — converting bytes into human units and comparing against a cap — Measured area 3.258% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · Drag and Drop File Upload UI: 9 Computed Dropzones
The post this demo lives in
Drag and Drop File Upload UI: 9 Computed DropzonesDropped files turn into previews computed from their bytes: type, size and count drive the layout, images get pixel-read See all demos in this post
If you like this effect - similar demos
drag01 FLIP list — computing the before/after offset and releasing it into a transitionDrag to Reorder UI: 9 Lists, One Drop Index
drag01 Grid-Magnet Drag That Pulls an Item Into a Cell Within RangeDrag and Drop Animation CSS: 9 Physics Patterns
drag04 A Sidebar Edge That Clicks Into Three Detent WidthsCSS Resize Handle: 9 Demos That Follow Your Grip