06붙여넣기가 넘치면 잘라 내고 감쇠 진동으로 알리기
붙여넣은 길이가 남은 칸을 넘으면 앞부분만 받고, 감쇠 사인 함수로 입력칸이 좌우로 흔들리다 가라앉으며 잘렸다는 것을 알린다.
쓰임긴 글을 붙여넣는 폼
PASTE_LIMITSHAKE_AMPSHAKE_DECAY_MSSHAKE_OMEGATOAST_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">메모</label>
<div class="cc__field" id="ccField">
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</div>
<div class="cc__meter" aria-hidden="true"><i class="cc__fill" id="ccFill"></i></div>
<div class="cc__row">
<div class="cc__big"><span class="cc__num" id="ccCount">0</span><span class="cc__unit">/ 80자</span></div>
<span class="cc__toast" id="ccToast"></span>
</div>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 흔들림은 CSS 키프레임이 아니라 JS 가 시각으로 계산한 감쇠 사인이다. --edge 가 테두리 색을 같이 가라앉힌다.
@include stage($stage-ink);
.cc {
width: min(100%, 420px);
display: grid;
gap: $sp-2;
color: $stage-paper;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba($stage-paper, .82);
}
.cc__field {
--edge: 0;
position: relative;
border-radius: $r-sm;
}
.cc__field::after {
content: "";
position: absolute;
inset: -3px;
border-radius: $r-card;
box-shadow: 0 0 0 3px $subject-pink;
opacity: var(--edge);
pointer-events: none;
}
.cc__ta {
box-sizing: border-box;
display: block;
width: 100%;
height: 64px;
resize: none;
padding: $sp-2 $sp-3;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-sm;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
font-size: 13px;
line-height: 18px;
}
.cc__ta::placeholder { color: rgba($stage-paper, .7); }
.cc__ta:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.cc__meter {
height: 6px;
overflow: hidden;
border-radius: $r-pill;
background: rgba($stage-paper, .12);
}
.cc__fill {
display: block;
height: 100%;
border-radius: $r-pill;
background: $subject-mint;
transform: scaleX(0);
transform-origin: 0 50%;
}
.cc__row { display: flex; align-items: center; justify-content: space-between; gap: $sp-3; min-height: 28px; }
.cc__big { display: flex; align-items: baseline; gap: $sp-1; }
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: $subject-mint;
font-variant-numeric: tabular-nums;
}
.cc__unit { font-size: 12px; font-weight: 600; color: rgba($stage-paper, .82); }
.cc__toast {
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: $subject-ink;
background: $subject-pink;
opacity: 0;
}
.cc__toast.is-on { opacity: 1; }
.cc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.cc__num { font-size: 20px; line-height: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__field { transform: none; }
.cc__field::after { opacity: 0; }
.cc__fill { transform-origin: 0 50%; }
}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);
}
.cc {
width: min(100%, 420px);
display: grid;
gap: 8px;
color: #ffffff;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
}
.cc__field {
--edge: 0;
position: relative;
border-radius: 8px;
}
.cc__field::after {
content: "";
position: absolute;
inset: -3px;
border-radius: 16px;
box-shadow: 0 0 0 3px #ff9ebb;
opacity: var(--edge);
pointer-events: none;
}
.cc__ta {
box-sizing: border-box;
display: block;
width: 100%;
height: 64px;
resize: none;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
font-size: 13px;
line-height: 18px;
}
.cc__ta::placeholder {
color: rgba(255, 255, 255, 0.7);
}
.cc__ta:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.cc__meter {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.cc__fill {
display: block;
height: 100%;
border-radius: 999px;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: 0 50%;
}
.cc__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
}
.cc__big {
display: flex;
align-items: baseline;
gap: 4px;
}
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.cc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
}
.cc__toast {
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: #17141a;
background: #ff9ebb;
opacity: 0;
}
.cc__toast.is-on {
opacity: 1;
}
.cc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.cc__num {
font-size: 20px;
line-height: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__field {
transform: none;
}
.cc__field::after {
opacity: 0;
}
.cc__fill {
transform-origin: 0 50%;
}
}(function () {
// 기법: 붙여넣기 이벤트를 가로채 남은 칸만큼의 앞부분(자소 묶음 기준)만 받고 나머지는 버린다.
// 잘렸다는 것은 입력칸이 감쇠 사인으로 좌우로 흔들리다 가라앉는 것으로 알린다 (진폭 x 지수 감쇠 x 사인, 난수 없음).
// 시연은 paste 이벤트와 같은 applyPaste 함수를 직접 부른다.
var PASTE_LIMIT = 80;
var SHAKE_AMP = 7;
var SHAKE_DECAY_MS = 240;
var SHAKE_OMEGA = 0.07;
var TOAST_MS = 1500;
var START_MS = 200;
var CHUNK_MS = 120;
var CYCLE_MS = 4800;
var PASTE_AT = [1500, 2900];
var CHUNKS = ['오늘 저녁에', '친구들과', '새로 생긴', '식당에 가서', '따뜻한 국물과', '바삭한 튀김을', '나눠 먹기로', '했다 그런데'];
var PASTE_SAMPLE = '그리고 후식으로 달콤한 디저트와 차까지 시켜서 천천히 먹었다';
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var field = document.getElementById('ccField');
var fill = document.getElementById('ccFill');
var countEl = document.getElementById('ccCount');
var toast = document.getElementById('ccToast');
var sr = document.getElementById('ccSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var seg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
function graphemes(s) {
if (!seg) return Array.from(s);
return Array.from(seg.segment(s), function (x) { return x.segment; });
}
var used = 0;
var shown = 0;
var shakeAt = -1;
var toastAt = -1;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function refresh() {
used = graphemes(ta.value).length;
countEl.textContent = String(used);
if (reduce) { shown = used; paintFill(); }
}
function paintFill() {
fill.style.transform = 'scaleX(' + Math.min(1, shown / PASTE_LIMIT).toFixed(4) + ')';
}
function signal(dropped, nowMs) {
toast.textContent = '잘림 ' + dropped + '자';
sr.textContent = '붙여넣기가 한도를 넘어 잘렸어요';
if (reduce) { toast.className = 'cc__toast is-on'; return; }
shakeAt = nowMs;
toastAt = nowMs;
}
function applyPaste(text, nowMs) {
var start = ta.selectionStart;
var end = ta.selectionEnd;
var before = ta.value.slice(0, start);
var after = ta.value.slice(end);
var room = PASTE_LIMIT - graphemes(before + after).length;
var incoming = graphemes(text);
var take = incoming.slice(0, Math.max(0, room)).join('');
ta.value = before + take + after;
ta.setSelectionRange(start + take.length, start + take.length);
refresh();
if (incoming.length > room) signal(incoming.length - Math.max(0, room), nowMs);
}
function stopDemo() { root.classList.remove('is-demo'); }
ta.addEventListener('focus', stopDemo);
ta.addEventListener('keydown', stopDemo);
ta.addEventListener('paste', function (e) {
stopDemo();
e.preventDefault();
applyPaste(e.clipboardData ? e.clipboardData.getData('text') : '', perfNow());
});
ta.addEventListener('input', function () {
stopDemo();
var list = graphemes(ta.value);
if (list.length > PASTE_LIMIT) {
ta.value = list.slice(0, PASTE_LIMIT).join('');
signal(list.length - PASTE_LIMIT, perfNow());
}
refresh();
});
var lastN = -1;
var pasteDone = [false, false];
var cycleStart = START_MS;
function paintMotion(nowMs) {
var dx = 0;
var edge = 0;
if (shakeAt >= 0) {
var age = nowMs - shakeAt;
var amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
if (amp < 0.1) { shakeAt = -1; } else { dx = amp * Math.sin(age * SHAKE_OMEGA); edge = amp / SHAKE_AMP; }
}
field.style.transform = dx === 0 ? '' : 'translateX(' + dx.toFixed(2) + 'px)';
field.style.setProperty('--edge', edge.toFixed(3));
if (toastAt >= 0) {
var ta2 = nowMs - toastAt;
if (ta2 >= TOAST_MS) { toastAt = -1; toast.className = 'cc__toast'; } else {
toast.className = 'cc__toast is-on';
var rise = Math.min(1, ta2 / 160);
toast.style.transform = 'translateY(' + ((1 - rise) * 8).toFixed(2) + 'px)';
toast.style.opacity = String(rise);
}
}
}
var lastTs = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (root.classList.contains('is-demo') && now >= START_MS) {
var local = now - cycleStart;
if (local >= CYCLE_MS) {
cycleStart += CYCLE_MS;
local = now - cycleStart;
pasteDone = [false, false];
lastN = -1;
ta.value = '';
refresh();
}
var n = Math.min(CHUNKS.length, Math.floor(local / CHUNK_MS) + 1);
if (n !== lastN && local < PASTE_AT[0]) {
lastN = n;
ta.value = CHUNKS.slice(0, n).join(' ');
refresh();
}
for (var p = 0; p < PASTE_AT.length; p += 1) {
if (!pasteDone[p] && local >= PASTE_AT[p]) {
pasteDone[p] = true;
var len = ta.value.length;
ta.setSelectionRange(len, len);
applyPaste(PASTE_SAMPLE, now);
}
}
}
shown += (used - shown) * (1 - Math.exp(-dt * 14));
paintFill();
paintMotion(now);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = CHUNKS.join(' ');
var endPos = ta.value.length;
ta.setSelectionRange(endPos, endPos);
applyPaste(PASTE_SAMPLE, 0);
} else {
requestAnimationFrame(tick);
}
})();// PasteClipShake — vanilla 판(06-paste-clip-overflow-damped-shake) 의 React 포트.
// 붙여넣기가 남은 칸을 넘으면 앞부분(자소 묶음 기준)만 받고, 감쇠 사인으로 입력칸을 흔들어 잘렸다고 알린다.
import { useEffect, useRef } from "react";
import styles from "./PasteClipShake.module.scss";
type Box<T> = { current: T };
type SegmenterLike = { segment(s: string): Iterable<{ segment: string }> };
type SegmenterCtor = new (locale: string | undefined, options: { granularity: string }) => SegmenterLike;
const PASTE_LIMIT = 80;
const SHAKE_AMP = 7;
const SHAKE_DECAY_MS = 240;
const SHAKE_OMEGA = 0.07;
const TOAST_MS = 1500;
const START_MS = 200;
const CHUNK_MS = 120;
const CYCLE_MS = 4800;
const PASTE_AT = [1500, 2900];
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데"];
const PASTE_SAMPLE = "그리고 후식으로 달콤한 디저트와 차까지 시켜서 천천히 먹었다";
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
export default function PasteClipShake() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const fieldRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const fillRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const toastRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const field = fieldRef.current;
const fill = fillRef.current;
const countEl = countRef.current;
const toast = toastRef.current;
const sr = srRef.current;
if (!ta || !field || !fill || !countEl || !toast || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seg = makeSegmenter();
const graphemes = (s: string): string[] =>
seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
let demo = true;
let used = 0;
let shown = 0;
let shakeAt = -1;
let toastAt = -1;
let lastN = -1;
let pasteDone = [false, false];
let cycleStart = START_MS;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paintFill(): void {
if (!fill) return;
fill.style.transform = "scaleX(" + Math.min(1, shown / PASTE_LIMIT).toFixed(4) + ")";
}
function refresh(): void {
if (!ta || !countEl) return;
used = graphemes(ta.value).length;
countEl.textContent = String(used);
if (reduce) { shown = used; paintFill(); }
}
function signal(dropped: number, nowMs: number): void {
if (!toast || !sr) return;
toast.textContent = "잘림 " + dropped + "자";
sr.textContent = "붙여넣기가 한도를 넘어 잘렸어요";
if (reduce) { toast.className = [styles.cc__toast, styles["is-on"]].join(" "); return; }
shakeAt = nowMs;
toastAt = nowMs;
}
function applyPaste(text: string, nowMs: number): void {
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const before = ta.value.slice(0, start);
const after = ta.value.slice(end);
const room = PASTE_LIMIT - graphemes(before + after).length;
const incoming = graphemes(text);
const take = incoming.slice(0, Math.max(0, room)).join("");
ta.value = before + take + after;
ta.setSelectionRange(start + take.length, start + take.length);
refresh();
if (incoming.length > room) signal(incoming.length - Math.max(0, room), nowMs);
}
function stopDemo(): void { demo = false; }
const onPaste = (e: ClipboardEvent): void => {
stopDemo();
e.preventDefault();
applyPaste(e.clipboardData ? e.clipboardData.getData("text") : "", perfNow());
};
const onInput = (): void => {
stopDemo();
const list = graphemes(ta.value);
if (list.length > PASTE_LIMIT) {
ta.value = list.slice(0, PASTE_LIMIT).join("");
signal(list.length - PASTE_LIMIT, perfNow());
}
refresh();
};
ta.addEventListener("focus", stopDemo);
ta.addEventListener("keydown", stopDemo);
ta.addEventListener("paste", onPaste);
ta.addEventListener("input", onInput);
function paintMotion(nowMs: number): void {
if (!field || !toast) return;
let dx = 0;
let edge = 0;
if (shakeAt >= 0) {
const age = nowMs - shakeAt;
const amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
if (amp < 0.1) { shakeAt = -1; } else { dx = amp * Math.sin(age * SHAKE_OMEGA); edge = amp / SHAKE_AMP; }
}
field.style.transform = dx === 0 ? "" : "translateX(" + dx.toFixed(2) + "px)";
field.style.setProperty("--edge", edge.toFixed(3));
if (toastAt >= 0) {
const age2 = nowMs - toastAt;
if (age2 >= TOAST_MS) {
toastAt = -1;
toast.className = styles.cc__toast;
} else {
toast.className = [styles.cc__toast, styles["is-on"]].join(" ");
const rise = Math.min(1, age2 / 160);
toast.style.transform = "translateY(" + ((1 - rise) * 8).toFixed(2) + "px)";
toast.style.opacity = String(rise);
}
}
}
function tick(ts: number): void {
if (!ta) return;
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo && now >= START_MS) {
let local = now - cycleStart;
if (local >= CYCLE_MS) {
cycleStart += CYCLE_MS;
local = now - cycleStart;
pasteDone = [false, false];
lastN = -1;
ta.value = "";
refresh();
}
const n = Math.min(CHUNKS.length, Math.floor(local / CHUNK_MS) + 1);
if (n !== lastN && local < PASTE_AT[0]) {
lastN = n;
ta.value = CHUNKS.slice(0, n).join(" ");
refresh();
}
for (let p = 0; p < PASTE_AT.length; p += 1) {
if (!pasteDone[p] && local >= PASTE_AT[p]) {
pasteDone[p] = true;
const len = ta.value.length;
ta.setSelectionRange(len, len);
applyPaste(PASTE_SAMPLE, now);
}
}
}
shown += (used - shown) * (1 - Math.exp(-dt * 14));
paintFill();
paintMotion(now);
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = CHUNKS.join(" ");
const endPos = ta.value.length;
ta.setSelectionRange(endPos, endPos);
applyPaste(PASTE_SAMPLE, 0);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
ta.removeEventListener("focus", stopDemo);
ta.removeEventListener("keydown", stopDemo);
ta.removeEventListener("paste", onPaste);
ta.removeEventListener("input", onInput);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<div className={styles.cc__field} ref={fieldRef}>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</div>
<div className={styles.cc__meter} aria-hidden="true"><i className={styles.cc__fill} ref={fillRef}></i></div>
<div className={styles.cc__row}>
<div className={styles.cc__big}><span className={styles.cc__num} id="ccCount" ref={countRef}>0</span><span className={styles.cc__unit}>/ 80자</span></div>
<span className={styles.cc__toast} ref={toastRef}></span>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}06 붙여넣기가 넘치면 잘라 내고 감쇠 진동으로 알리기 — 측정 누적면적 12.012% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창
이 데모가 속한 글
textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창글자수는 length 한 줄로 끝날 것 같지만, 이모지 하나가 2로 올라가고 한글 조합 중에는 숫자가 깜박인다 — 01번과 04번이 그 두 군데를 하나씩 막는다. textarea 글자수 카운터는 입력이 바뀔 때마다 이 글의 데모 전부 보기


