03Heading line-wrap balance — a paragraph that keeps a lone word off the last line
When a long heading wraps across lines, each line's character count balances evenly, preventing a single orphan word from being stranded on the last line.
Best forA card title, a hero-section headline
text-wrap: balanceCSS.supportstext-wrap

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<span class="supportBadge" id="supportBadge">Checking</span>
<button class="toggleBtn" id="toggleBtn" type="button" aria-pressed="false">Balanced line-wrap</button>
</div>
<div class="frame">
<h3 class="heading" id="heading">Nine CSS reset techniques covering browser defaults through safe-area padding</h3>
<div class="bars" id="bars"></div>
</div>
<div class="readout" id="readout">Lines — · deviation —px</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 render() 에 있다 — Range.getClientRects() 로 줄마다 실제 렌더 폭을
// 다시 재고, 표준편차(stdDev)를 문구로 보여준다. 여기는 줄 막대의 모양과 균형 전환만 맡는다.
@include stage($stage-ink);
.wrap {
width: 300px;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.supportBadge {
color: $subject-cream;
font-size: 10px;
font-weight: 600;
background: rgba($subject-cream, .1);
border-radius: $r-xs;
padding: $sp-1 $sp-2;
&.fallback { background: rgba($subject-orange-deep, .45); }
}
.toggleBtn {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-orange-deep;
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background $dur-instant $ease-out, transform $dur-instant $ease-out;
&:hover { background: $subject-orange-deep; filter: brightness(1.2); }
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-blue-light; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.frame {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.heading {
margin: 0;
color: $subject-cream;
font-size: 15px;
font-weight: 700;
line-height: 1.45;
width: 260px;
}
.bars {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.bar {
height: 6px;
width: 260px;
border-radius: $r-xs;
background: $subject-blue-light;
transform-origin: left;
}
.readout {
color: $subject-cream;
font-size: 11px;
opacity: .82;
}
@media (max-width: 340px) {
.wrap { width: 260px; }
.heading, .bar { width: 230px; }
}
@media (prefers-reduced-motion: reduce) {
// reduce: 자동 루프 토글만 멈춘다(버튼으로 하는 실사용 전환은 즉시 상태만 바뀌면 된다)
.toggleBtn { 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);
}
.wrap {
width: 300px;
display: flex;
flex-direction: column;
gap: 12px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.supportBadge {
color: #fff7e6;
font-size: 10px;
font-weight: 600;
background: rgba(255, 247, 230, 0.1);
border-radius: 4px;
padding: 4px 8px;
}
.supportBadge.fallback {
background: rgba(201, 48, 0, 0.45);
}
.toggleBtn {
appearance: none;
border: none;
border-radius: 999px;
background: #c93000;
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background 140ms ease-out, transform 140ms ease-out;
}
.toggleBtn:hover {
background: #c93000;
filter: brightness(1.2);
}
.toggleBtn:active {
transform: scale(0.96);
}
.toggleBtn[aria-pressed=true] {
background: #5b8cf8;
}
.toggleBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.frame {
display: flex;
flex-direction: column;
gap: 8px;
}
.heading {
margin: 0;
color: #fff7e6;
font-size: 15px;
font-weight: 700;
line-height: 1.45;
width: 260px;
}
.bars {
display: flex;
flex-direction: column;
gap: 4px;
}
.bar {
height: 6px;
width: 260px;
border-radius: 4px;
background: #5b8cf8;
transform-origin: left;
}
.readout {
color: #fff7e6;
font-size: 11px;
opacity: 0.82;
}
@media (max-width: 340px) {
.wrap {
width: 260px;
}
.heading, .bar {
width: 230px;
}
}
@media (prefers-reduced-motion: reduce) {
.toggleBtn {
transition: none;
}
}(function () {
var STEP_MS = 220;
var MAX_BARS = 4;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var supported = typeof CSS !== 'undefined' && CSS.supports && CSS.supports('text-wrap', 'balance');
var wrap = document.getElementById('wrap');
var heading = document.getElementById('heading');
var toggleBtn = document.getElementById('toggleBtn');
var supportBadge = document.getElementById('supportBadge');
var bars = document.getElementById('bars');
var readout = document.getElementById('readout');
var isDemo = true;
var on = false;
supportBadge.textContent = supported ? 'text-wrap: balance supported' : 'Not supported — fallback (no balancing)';
supportBadge.classList.toggle('fallback', !supported);
function lineWidths() {
var range = document.createRange();
range.selectNodeContents(heading);
var rects = range.getClientRects();
var out = [];
for (var i = 0; i < rects.length; i++) out.push(rects[i].width);
return out;
}
function stdDev(vals) {
if (vals.length < 2) return 0;
var mean = vals.reduce(function (a, b) { return a + b; }, 0) / vals.length;
var variance = vals.reduce(function (a, b) { return a + (b - mean) * (b - mean); }, 0) / vals.length;
return Math.sqrt(variance);
}
function render() {
var widths = lineWidths();
var maxW = heading.getBoundingClientRect().width || 1;
var html = '';
for (var i = 0; i < MAX_BARS; i++) {
var w = widths[i] || 0;
html += '<div class="bar" style="transform: scaleX(' + (w / maxW).toFixed(3) + '); opacity: ' + (widths[i] ? 1 : 0) + '"></div>';
}
bars.innerHTML = html;
readout.textContent = 'Lines ' + widths.length + ' · deviation ±' + Math.round(stdDev(widths)) + 'px';
}
function apply(next) {
on = next && supported;
heading.style.textWrap = on ? 'balance' : 'wrap';
toggleBtn.setAttribute('aria-pressed', String(on));
render();
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
toggleBtn.addEventListener('click', function () { human(); apply(!on); });
var last = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && supported && ts - last >= STEP_MS) { last = ts; apply(!on); }
requestAnimationFrame(loop);
}
apply(false);
requestAnimationFrame(loop);
})();// TextWrap — vanilla 판(run/376/demo/03-text-wrap/index.html) 의 React 포트.
// Range.getClientRects() 로 실제 렌더된 줄마다의 폭을 재고, text-wrap: balance 켜짐/꺼짐에
// 따른 줄 수·너비 편차(표준편차)를 계산해 막대와 문구로 보여준다.
import { useEffect, useRef } from "react";
import styles from "./TextWrap.module.scss";
type Box<T> = { current: T };
const STEP_MS = 220;
const MAX_BARS = 4;
export default function TextWrap() {
const wrapRef = useRef(null) as unknown as Box<HTMLDivElement>;
const headingRef = useRef(null) as unknown as Box<HTMLHeadingElement>;
const toggleBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const supportBadgeRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const barsRef = useRef(null) as unknown as Box<HTMLDivElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = wrapRef.current;
const heading = headingRef.current;
const toggleBtn = toggleBtnRef.current;
const supportBadge = supportBadgeRef.current;
const bars = barsRef.current;
const readout = readoutRef.current;
if (!wrap || !heading || !toggleBtn || !supportBadge || !bars || !readout) return;
const supported = typeof CSS !== "undefined" && !!CSS.supports && CSS.supports("text-wrap", "balance");
let isDemo = true;
let on = false;
supportBadge.textContent = supported ? "text-wrap: balance 지원" : "미지원 — 폴백(균형 없음)";
supportBadge.classList.toggle(styles.fallback, !supported);
function lineWidths(): number[] {
const range = document.createRange();
range.selectNodeContents(heading!);
const rects = range.getClientRects();
const out: number[] = [];
for (let i = 0; i < rects.length; i++) out.push(rects[i].width);
return out;
}
function stdDev(vals: number[]): number {
if (vals.length < 2) return 0;
const mean = vals.reduce(function (a, b) { return a + b; }, 0) / vals.length;
const variance = vals.reduce(function (a, b) { return a + (b - mean) * (b - mean); }, 0) / vals.length;
return Math.sqrt(variance);
}
function render() {
const widths = lineWidths();
const maxW = heading!.getBoundingClientRect().width || 1;
let html = "";
for (let i = 0; i < MAX_BARS; i++) {
const w = widths[i] || 0;
html += '<div class="' + styles.bar + '" style="transform: scaleX(' + (w / maxW).toFixed(3) + '); opacity: ' + (widths[i] ? 1 : 0) + '"></div>';
}
bars!.innerHTML = html;
readout!.textContent = "줄 " + widths.length + "개 · 편차 ±" + Math.round(stdDev(widths)) + "px";
}
function apply(next: boolean) {
on = next && supported;
heading!.style.setProperty("text-wrap", on ? "balance" : "wrap");
toggleBtn!.setAttribute("aria-pressed", String(on));
render();
}
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function onToggleClick() { human(); apply(!on); }
toggleBtn.addEventListener("click", onToggleClick);
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && supported && ts - last >= STEP_MS) { last = ts; apply(!on); }
rafRef.current = requestAnimationFrame(loop);
}
apply(false);
rafRef.current = requestAnimationFrame(loop);
return () => {
toggleBtn!.removeEventListener("click", onToggleClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={wrapRef}>
<div className={styles.head}>
<span className={styles.supportBadge} ref={supportBadgeRef}>확인 중</span>
<button className={styles.toggleBtn} ref={toggleBtnRef} type="button" aria-pressed="false">균형 줄바꿈</button>
</div>
<div className={styles.frame}>
<h3 className={styles.heading} ref={headingRef}>브라우저 기본값 리셋과 안전 영역까지 정리한 css 초기화 아홉 가지</h3>
<div className={styles.bars} ref={barsRef}></div>
</div>
<div className={styles.readout} ref={readoutRef}>줄 —개 · 편차 —px</div>
</div>
</main>
);
}03 Heading line-wrap balance — a paragraph that keeps a lone word off the last line — Measured area 3.253% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Reset: 9 Patterns From Basics to Safe-Area
The post this demo lives in
CSS Reset: 9 Patterns From Basics to Safe-AreaNine css reset patterns: one recalculates padding for the safe area, one shrinks transition duration near zero when moti See all demos in this post
If you like this effect - similar demos
base04 Paragraph rhythm previewCSS Typography Playground: 9 Demos, No Webfonts
base01 Squircle card — a superellipse exponent value flows between circle and square while the card's corner ridge changesCSS corner-shape: 9 Corner Cutting Patterns
Localization06 Hardcoded text that skips the packi18n JSON Checks: 9 Ways to Catch Missing Keys