09Rich-text toast — a notice whose label width recomputes by style-kind count
The style kinds mixed into the copied content (bold, italic, code, and so on) are counted, the toast's label width is recomputed by that count, and kind badges attach in order.
Best forEditor copy confirmations, carrying email drafts over
querySelectorscrollWidthcreateElement

<main class="stage">
<section class="panel">
<p class="preview" id="preview">
This paragraph mixes <strong>bold</strong>, <em>italic</em>, and <code>code</code>
</p>
<button class="copyBtn" id="copyBtn" type="button">Copy with formatting</button>
<div class="toast" id="toast">
<span class="toastLabel" id="toastLabel">0 style kinds copied</span>
<span class="badges" id="badges"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.panel {
box-sizing: border-box;
width: 320px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-4;
background: $stage-paper;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
}
.preview {
margin: 0;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
line-height: 1.6;
strong { color: $subject-blue-deep; }
em { color: $subject-orange-deep; }
code {
font-family: "SFMono-Regular", Consolas, monospace;
background: rgba($subject-ink, .08);
border-radius: $r-xs;
padding: 4px 4px;
}
}
.copyBtn {
align-self: flex-start;
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: $subject-blue-deep;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
.toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
min-width: 140px;
width: fit-content;
max-width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
opacity: 0;
transition: min-width $dur-quick $ease-out, opacity $dur-quick $ease-out;
}
.toast.is-visible {
opacity: 1;
}
.toastLabel {
color: $stage-paper;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badges {
display: inline-flex;
gap: $sp-1;
}
.badge {
display: inline-flex;
align-items: center;
padding: 4px $sp-2;
border-radius: $r-xs;
background: $subject-blue-light;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
@media (max-width: 340px) {
.panel {
width: 260px;
padding: $sp-4;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
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);
}
.panel {
box-sizing: border-box;
width: 320px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
background: #ffffff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.preview {
margin: 0;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
line-height: 1.6;
}
.preview strong {
color: #0a4fe8;
}
.preview em {
color: #c93000;
}
.preview code {
font-family: "SFMono-Regular", Consolas, monospace;
background: rgba(23, 20, 26, 0.08);
border-radius: 4px;
padding: 4px 4px;
}
.copyBtn {
align-self: flex-start;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #0a4fe8;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
.toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
min-width: 140px;
width: fit-content;
max-width: 100%;
padding: 8px 12px;
border-radius: 999px;
background: #17141a;
opacity: 0;
transition: min-width 220ms ease-out, opacity 220ms ease-out;
}
.toast.is-visible {
opacity: 1;
}
.toastLabel {
color: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badges {
display: inline-flex;
gap: 4px;
}
.badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
background: #5b8cf8;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
@media (max-width: 340px) {
.panel {
width: 260px;
padding: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
transition: none;
}
}// style 종류 토스트 — 복사된 내용에 서식이 몇 종류 섞였는지 세어(bold·italic·code)
// 그 수만큼 토스트의 라벨 폭을 다시 계산해 늘리고 종류 뱃지가 순서대로 붙는다.
// 계산 겹: DOM 태그 종류 카운트 → 라벨 실측 scrollWidth → 뱃지 폭 합산 → 토스트 min-width 재계산.
// 실사용자 리스너 있음(Copy with formatting 버튼). 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
(function () {
var preview = document.getElementById('preview');
var copyBtn = document.getElementById('copyBtn');
var toast = document.getElementById('toast');
var toastLabel = document.getElementById('toastLabel');
var badgesEl = document.getElementById('badges');
if (!preview || !copyBtn || !toast || !toastLabel || !badgesEl) return;
var KIND_LABELS = { strong: 'bold', em: 'italic', code: 'code' };
var BADGE_STAGGER_MS = 160;
// 붙여넣는 문단이 바뀌는 상황을 흉내낸다 — style 종류 수가 1·2·3 종으로 달라진다
var SAMPLES = [
'This paragraph has only <strong>bold</strong>',
'This paragraph mixes <strong>bold</strong> and <em>italic</em>',
'This paragraph mixes <strong>bold</strong>, <em>italic</em>, and <code>code</code>'
];
var SAMPLE_MS = 660;
var CYCLE_MS = SAMPLE_MS * SAMPLES.length;
function countKinds() {
var found = [];
var tags = ['strong', 'em', 'code'];
for (var i = 0; i < tags.length; i++) {
if (preview.querySelector(tags[i])) found.push(tags[i]);
}
return found;
}
function measureAndSize(count) {
var labelWidth = toastLabel.scrollWidth;
var badgeWidth = count * 46;
var total = labelWidth + badgeWidth + 40;
toast.style.minWidth = total + 'px';
}
var badgeTimers = [];
function clearBadgeTimers() {
for (var i = 0; i < badgeTimers.length; i++) clearTimeout(badgeTimers[i]);
badgeTimers = [];
}
function showToast(sampleIndex) {
preview.innerHTML = SAMPLES[sampleIndex];
var kinds = countKinds();
clearBadgeTimers();
toastLabel.textContent = 'style ' + kinds.length + ' kinds copied';
badgesEl.innerHTML = '';
measureAndSize(0);
toast.classList.add('is-visible');
for (var i = 0; i < kinds.length; i++) {
(function (kind, index) {
var timer = setTimeout(function () {
var b = document.createElement('span');
b.className = 'badge';
b.textContent = KIND_LABELS[kind];
badgesEl.appendChild(b);
measureAndSize(index + 1);
}, index * BADGE_STAGGER_MS);
badgeTimers.push(timer);
})(kinds[i], i);
}
}
showToast(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
var start = null;
var lastSample = -1;
function autoLoop(ts) {
if (start === null) start = ts;
var elapsed = ts - start;
var t = elapsed % CYCLE_MS;
var sampleIndex = Math.floor(t / SAMPLE_MS) % SAMPLES.length;
if (sampleIndex !== lastSample) {
lastSample = sampleIndex;
showToast(sampleIndex);
}
requestAnimationFrame(autoLoop);
}
requestAnimationFrame(autoLoop);
copyBtn.addEventListener('click', function () {
lastSample = -1;
showToast(0);
});
})();import { useEffect, useRef } from "react";
import styles from "./CopyRichTextToast.module.scss";
// 서식 종류 토스트 — 복사된 내용에 서식이 몇 종류 섞였는지 세어(굵게·기울임·코드)
// 그 수만큼 토스트의 라벨 폭을 다시 계산해 늘리고 종류 뱃지가 순서대로 붙는다.
// 계산 겹: DOM 태그 종류 카운트 → 라벨 실측 scrollWidth → 뱃지 폭 합산 → 토스트 min-width 재계산.
// 실사용자 리스너 있음(서식째 복사 버튼). 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
const KIND_LABELS: Record<string, string> = { strong: "굵게", em: "기울임", code: "코드" };
const BADGE_STAGGER_MS = 160;
const SAMPLES = [
'이 문단은 <strong>굵게</strong>만 있어요',
'이 문단은 <strong>굵게</strong>와 <em>기울임</em>이 섞여 있어요',
'이 문단은 <strong>굵게</strong>와 <em>기울임</em>과 <code>코드</code>가 섞여 있어요',
];
const SAMPLE_MS = 660;
const CYCLE_MS = SAMPLE_MS * SAMPLES.length;
export default function CopyRichTextToast() {
const previewRef = useRef<HTMLParagraphElement>(null);
const copyBtnRef = useRef<HTMLButtonElement>(null);
const toastRef = useRef<HTMLDivElement>(null);
const toastLabelRef = useRef<HTMLSpanElement>(null);
const badgesRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const preview = previewRef.current;
const copyBtn = copyBtnRef.current;
const toast = toastRef.current;
const toastLabel = toastLabelRef.current;
const badgesEl = badgesRef.current;
if (!preview || !copyBtn || !toast || !toastLabel || !badgesEl) return;
let badgeTimers: number[] = [];
function clearBadgeTimers() {
badgeTimers.forEach((id) => clearTimeout(id));
badgeTimers = [];
}
function countKinds() {
const found: string[] = [];
["strong", "em", "code"].forEach((tag) => {
if (preview!.querySelector(tag)) found.push(tag);
});
return found;
}
function measureAndSize(count: number) {
const labelWidth = toastLabel!.scrollWidth;
const badgeWidth = count * 46;
const total = labelWidth + badgeWidth + 40;
toast!.style.minWidth = `${total}px`;
}
function showToast(sampleIndex: number) {
preview!.innerHTML = SAMPLES[sampleIndex];
const kinds = countKinds();
clearBadgeTimers();
toastLabel!.textContent = `서식 ${kinds.length}종 복사됨`;
badgesEl!.innerHTML = "";
measureAndSize(0);
toast!.classList.add(styles.isVisible);
kinds.forEach((kind, index) => {
const timer = window.setTimeout(() => {
const b = document.createElement("span");
b.className = styles.badge;
b.textContent = KIND_LABELS[kind];
badgesEl!.appendChild(b);
measureAndSize(index + 1);
}, index * BADGE_STAGGER_MS);
badgeTimers.push(timer);
});
}
showToast(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let raf = 0;
if (!reduce) {
let start: number | null = null;
let lastSample = -1;
const autoLoop = (ts: number) => {
if (start === null) start = ts;
const elapsed = ts - start;
const t = elapsed % CYCLE_MS;
const sampleIndex = Math.floor(t / SAMPLE_MS) % SAMPLES.length;
if (sampleIndex !== lastSample) {
lastSample = sampleIndex;
showToast(sampleIndex);
}
raf = requestAnimationFrame(autoLoop);
};
raf = requestAnimationFrame(autoLoop);
}
function onCopy() {
showToast(0);
}
copyBtn.addEventListener("click", onCopy);
return () => {
if (raf) cancelAnimationFrame(raf);
clearBadgeTimers();
copyBtn.removeEventListener("click", onCopy);
};
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<p ref={previewRef} className={styles.preview}>
이 문단은 <strong>굵게</strong>와 <em>기울임</em>과 <code>코드</code>가 섞여 있어요
</p>
<button ref={copyBtnRef} className={styles.copyBtn} type="button">서식째 복사</button>
<div ref={toastRef} className={styles.toast}>
<span ref={toastLabelRef} className={styles.toastLabel}>서식 0종 복사됨</span>
<span ref={badgesRef} className={styles.badges} />
</div>
</section>
</div>
);
}09 Rich-text toast — a notice whose label width recomputes by style-kind count — Measured area 10.36% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · 9 Copy Button CSS UI Patterns (Copy to Clipboard)
The post this demo lives in
9 Copy Button CSS UI Patterns (Copy to Clipboard)Nine copy-button UIs: one splits the result across two buttons, one fills its progress by the character count it just co See all demos in this post
If you like this effect - similar demos
Interactions05 Width-aware auto truncation9 Breadcrumb UI Patterns That Survive Deep Paths
Interactions03 Copy-link toastSocial Share Buttons: 9 Copy-Paste UI Patterns
Interactions09 Clipboard copy — a basic button switching its label from a success-state valueMicro Interactions: 9 Essential UI Patterns