font-size: clamp(1rem, 0.875rem + 0.5vw, 1.5rem)のような fluid typography はもうレスポンシブ実装の標準になっている。media query なしで viewport 連動。だが「最小 16px、最大 24px、320px から 1280px の範囲でリニアに」と決まった時、中の係数を手計算する人はほぼいない。Utopia.fyi などの計算機にいつもお世話になっている。今回その種類のツールを 500 行 vanilla JS で書いた。線形補間の数学 と rem を使うアクセシビリティ的な配慮 を解説する。
🌐 Demo: https://sen.ltd/portfolio/css-clamp-calc/
📦 GitHub: https://github.com/sen-ltd/css-clamp-calc
fluid typography とは
/* 旧: media query で段階的に切替 */
font-size: 16px;
@media (min-width: 768px) { font-size: 20px; }
@media (min-width: 1200px) { font-size: 24px; }
/* 新: clamp() で viewport 連動 */
font-size: clamp(1rem, 0.875rem + 0.5vw, 1.5rem);
下の clamp() は viewport 幅に対して線形にサイズが変わる 関数。clamp(min, preferred, max) 形式で、preferred が viewport 連動の式、両端で min/max にクランプされる。
clamp() の中身の数学
「viewport が 320px のときに 16px、1280px のときに 24px」を「viewport の関数として線形補間する」と:
size(vwPx) = 16 + (24 - 16) × (vwPx - 320) / (1280 - 320)
= 16 + 8 × (vwPx - 320) / 960
= (16 - 8 × 320/960) + (8/960) × vwPx
= (16 - 2.667) + 0.00833 × vwPx
= 13.333 + 0.00833 × vwPx [px]
これを「intercept + slope × vw」の形に書き直す。CSS の vw 単位は viewport の 1% なので、vwPx = viewport_px = 100 × vw。よって:
size = 13.333 + 0.00833 × 100 × vw
= 13.333 + 0.833 × vw [px]
px → rem (16px = 1rem) で換算:
intercept = 13.333 / 16 = 0.833 rem
slope = 0.833 vw (vw単位はそのまま)
そして clamp() で包む:
font-size: clamp(1rem, 0.833rem + 0.833vw, 1.5rem);
これが手計算。実装としてはこれを関数に閉じ込める:
export function fluidLinear(minPx, maxPx, minVwPx, maxVwPx) {
if (maxVwPx === minVwPx) {
return { slopeVw: 0, interceptRem: minPx / 16, slopePx: 0, interceptPx: minPx };
}
const slopePx = (maxPx - minPx) / (maxVwPx - minVwPx);
const interceptPx = minPx - slopePx * minVwPx;
const slopeVw = slopePx * 100;
const interceptRem = interceptPx / 16;
return { slopeVw, interceptRem, slopePx, interceptPx };
}
なぜ rem を使うか — アクセシビリティ
ここが地味だが重要なポイント。intercept を rem で出すと、ユーザがブラウザの「文字サイズ」を変えたとき正しく追従する。
/* ❌ ピクセルハードコード — ユーザ設定を無視する */
font-size: clamp(16px, 13.33px + 0.833vw, 24px);
/* ✅ rem ベース — ユーザの root font-size に従う */
font-size: clamp(1rem, 0.833rem + 0.833vw, 1.5rem);
ユーザが Chrome の Settings > Appearance > Font size で「Large (20px)」に設定した場合:
- ピクセル版: 何も変わらない (16px 固定で押し付ける)
- rem 版:
1rem = 20pxになるので全体が 25% 大きくなる
ピクセル固定はアクセシビリティ的な反パターン。fluid typography で slope は vw のまま、intercept は rem に換算する のが正解。
slope の vw は viewport の比率 なので、viewport size が変わったときの追従としては rem 換算不要 (むしろ rem に換算すると意味が壊れる)。
clamp() のクランプ挙動
clamp(min, preferred, max) は preferred が:
- max を超えたら max を返す
- min を下回ったら min を返す
- それ以外は preferred そのまま
つまり viewport が 320px 未満では min 固定、1280px 超では max 固定、その間は線形。media query の代わりに 1 行で「上下を切る」 が出来るのが clamp() の本質。
ツール実装側:
export function evalAtViewport(minPx, maxPx, minVwPx, maxVwPx, vwPx) {
const { slopePx, interceptPx } = fluidLinear(minPx, maxPx, minVwPx, maxVwPx);
const linear = interceptPx + slopePx * vwPx;
return Math.min(maxPx, Math.max(minPx, linear));
}
これがブラウザがレイアウト計算時にやっていることと等価。preview ボックスはこの関数を呼んで style.fontSize を更新するだけ。
「intercept が負になる」のは正しい
太い slope (大きいサイズ delta) になると、intercept が 負の値 になる:
minPx=10, maxPx=60, minVwPx=320, maxVwPx=1280
slopePx = 50/960 = 0.052
interceptPx = 10 - 0.052 × 320 = -6.67
CSS としては:
font-size: clamp(0.625rem, -0.417rem + 5.208vw, 3.75rem);
「-0.417rem って大丈夫?」と一瞬思うが 全く問題ない。viewport が minVw=320px のとき:
-0.417rem + 5.208vw = -6.67px + 5.208 × 3.2px = -6.67 + 16.67 = 10px ✓
ちゃんと minPx=10px になる。clamp() の min 側でカットされる前に式自体が成立している。
ツールの notes パネルにも書いた:
Negative intercept is common and correct. For sharp slopes (large size delta), the intercept is often negative — that's why the math works out at the clamp's lower end. Don't "fix" it.
実装中に最初これを見て「バグかな」と思ったが、テストの「端点一致」が通っていたので冷静に式を追ったら正しいと分かった。
SVG chart で「視覚化」
数式だけだとピンと来ないので、size vs viewport の チャート も描いた:
// 60 サンプルで polyline 描画
for (let i = 0; i <= 60; i++) {
const x = xMin + (i / 60) * (xMax - xMin);
const y = evalAtViewport(minPx, maxPx, minVwPx, maxVwPx, x);
points.push(`${xToPx(x)},${yToPx(y)}`);
}
2 本描画する:
- clamp された曲線 (実線、青) — 両端が水平
- clamp していない素の線形 (破線、薄青) — 両端が範囲外に伸びていく
両者を重ねると 「clamp() が何を切っているか」が一目で分かる。viewport slider を動かすと cursor (pink) がチャート上を移動して preview と同期する。
単体テストの境界条件
test("interpolation hits minPx at minVwPx", () => {
const { slopePx, interceptPx } = fluidLinear(16, 24, 320, 1280);
assert.ok(Math.abs((interceptPx + slopePx * 320) - 16) < 1e-9);
});
test("at minVw: returns minPx exactly", () => {
const v = evalAtViewport(16, 24, 320, 1280, 320);
assert.ok(Math.abs(v - 16) < 1e-9);
});
test("below minVw: returns minPx", () => {
const v = evalAtViewport(16, 24, 320, 1280, 100);
assert.equal(v, 16);
});
test("at maxVw: returns maxPx exactly", () => {
const v = evalAtViewport(16, 24, 320, 1280, 1280);
assert.ok(Math.abs(v - 24) < 1e-9);
});
「端点で minPx/maxPx に 完全に一致 する」のと「範囲外でクランプされる」のを別々の test に切るのが大事。境界バグはここで止まる。
degenerate viewport range (minVw === maxVw) は minPx を返す ような edge case も含めて 19 件。
7 種のプリセット
export const PRESETS = [
{ label: "Body text", minPx: 16, maxPx: 18, minVwPx: 320, maxVwPx: 1280 },
{ label: "Lead paragraph", minPx: 18, maxPx: 22, minVwPx: 320, maxVwPx: 1280 },
{ label: "H3 heading", minPx: 20, maxPx: 28, minVwPx: 320, maxVwPx: 1440 },
{ label: "H2 heading", minPx: 24, maxPx: 40, minVwPx: 320, maxVwPx: 1440 },
{ label: "H1 hero", minPx: 32, maxPx: 72, minVwPx: 320, maxVwPx: 1440 },
{ label: "Display", minPx: 48, maxPx: 120, minVwPx: 320, maxVwPx: 1600 },
{ label: "Tight", minPx: 14, maxPx: 16, minVwPx: 480, maxVwPx: 960 },
];
「Tight」は viewport 範囲を狭く取った プリセット — モバイル中盤 (480-960px) でだけスケールさせて他は固定、という需要に。
アーキテクチャ
clamp.js ← fluidLinear, toClamp, evalAtViewport, validateConfig (DOM 非依存、19 tests)
presets.js ← 7 種のプリセット
app.js ← UI グルー (input → math → SVG draw → preview)
clamp.js には DOM が登場しない。Node テストで数学を完全に検証してから、UI 層は表示と入力ハンドリングだけを担当。drawChart() も SVG 文字列生成だけで、外部チャートライブラリは使わない。
まとめ
- fluid typography は clamp(min, intercept + slope·vw, max) の 1 行で書ける
- 線形補間の slope と intercept は端点 (minVw, minPx) / (maxVw, maxPx) から算術で出る
- intercept は rem で表現する とユーザのフォントサイズ設定が効く (アクセシビリティ的に正解)
- negative intercept は正しい — 大きい slope では普通に起きるし、両端で値は合っている
- chart で clamp 前の素の線形 + clamp 後の曲線 を重ねると挙動が直感的に分かる
- テストは「端点で 完全に一致」「範囲外でクランプ」を別 case に切ることで境界バグを止める
リポジトリ: https://github.com/sen-ltd/css-clamp-calc
このツールは弊社の OSS ポートフォリオ #254 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/
