04이전 값과의 차이를 굴려 올리는 좋아요 카운트 팝
좋아요를 누르면 이전 숫자와 새 숫자의 차이를 계산해 그 폭만큼 자리별 숫자가 굴러 올라가고, 버튼은 탄성 계수만큼 커졌다가 줄어든다.
쓰임댓글 좋아요 버튼
lk__digitTracklk__digitColrollDigit

<main class="stage">
<div class="lk is-demo" id="lkRoot">
<div class="lk__row" id="lkRow">
<span class="lk__avatar" aria-hidden="true"></span>
<div class="lk__body">
<p class="lk__meta"><strong class="lk__name">민트파도</strong><span class="lk__time">14분 전</span></p>
<p class="lk__text">이 방법 진짜 깔끔하네요, 바로 적용했어요.</p>
<button type="button" class="lk__likeBtn" id="lkBtn" aria-pressed="false" aria-label="좋아요">
<svg class="lk__heart" id="lkHeart" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
<span class="lk__count" id="lkCount" aria-hidden="true">
<span class="lk__digitCol"><span class="lk__digitTrack"><span class="lk__digit">2</span></span></span>
<span class="lk__digitCol"><span class="lk__digitTrack"><span class="lk__digit">4</span></span></span>
<span class="lk__digitCol"><span class="lk__digitTrack"><span class="lk__digit">9</span></span></span>
</span>
</button>
</div>
</div>
<p class="lk__sr" id="lkStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.lk {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $tick-panel;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
box-sizing: border-box;
padding: $sp-4;
display: flex;
align-items: center;
}
.lk__row { display: flex; align-items: flex-start; gap: $sp-2; width: 100%; border-radius: $r-sm; padding: $sp-2; margin: -#{$sp-2}; }
.lk__avatar {
flex: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: linear-gradient(160deg, $subject-sky, $subject-blue-light);
margin-top: 4px;
}
.lk__body { min-width: 0; flex: 1; }
.lk__meta { margin: 0 0 4px; display: flex; align-items: baseline; gap: $sp-2; }
.lk__name { color: #fff; font-size: 12.5px; font-weight: 700; }
.lk__time { color: rgba(255, 255, 255, .5); font-size: 10.5px; }
.lk__text { margin: 0 0 $sp-3; color: rgba(255, 255, 255, .86); font-size: 12.5px; line-height: 1.5; }
.lk__likeBtn {
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
background: rgba(255, 255, 255, .06);
color: rgba(255, 255, 255, .7);
padding: $sp-1 $sp-3;
border-radius: $r-pill;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
&.is-liked {
color: $subject-pink;
background: rgba(255, 158, 187, .14);
}
}
.lk__heart { width: 16px; height: 16px; transform-origin: center; }
.lk__count {
display: inline-flex;
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 12.5px;
line-height: 1;
}
.lk__digitCol {
position: relative;
width: 8px;
height: 14px;
overflow: hidden;
display: inline-block;
}
.lk__digitTrack { position: relative; width: 100%; height: 100%; }
.lk__digit {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.lk__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.lk { padding: $sp-3; }
.lk__text, .lk__name { font-size: 11.5px; }
}
@media (prefers-reduced-motion: reduce) {
.lk__digit, .lk__heart, .lk__likeBtn { transition: none !important; }
}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);
}
.lk {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #231f28;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
box-sizing: border-box;
padding: 16px;
display: flex;
align-items: center;
}
.lk__row {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
border-radius: 8px;
padding: 8px;
margin: -8px;
}
.lk__avatar {
flex: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: linear-gradient(160deg, #6ec8ff, #5b8cf8);
margin-top: 2px;
}
.lk__body {
min-width: 0;
flex: 1;
}
.lk__meta {
margin: 0 0 2px;
display: flex;
align-items: baseline;
gap: 8px;
}
.lk__name {
color: #fff;
font-size: 12.5px;
font-weight: 700;
}
.lk__time {
color: rgba(255, 255, 255, 0.5);
font-size: 10.5px;
}
.lk__text {
margin: 0 0 12px;
color: rgba(255, 255, 255, 0.86);
font-size: 12.5px;
line-height: 1.5;
}
.lk__likeBtn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
}
.lk__likeBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.lk__likeBtn.is-liked {
color: #ff9ebb;
background: rgba(255, 158, 187, 0.14);
}
.lk__heart {
width: 16px;
height: 16px;
transform-origin: center;
}
.lk__count {
display: inline-flex;
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 12.5px;
line-height: 1;
}
.lk__digitCol {
position: relative;
width: 8px;
height: 14px;
overflow: hidden;
display: inline-block;
}
.lk__digitTrack {
position: relative;
width: 100%;
height: 100%;
}
.lk__digit {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.lk__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.lk {
padding: 12px;
}
.lk__text, .lk__name {
font-size: 11.5px;
}
}
@media (prefers-reduced-motion: reduce) {
.lk__digit, .lk__heart, .lk__likeBtn {
transition: none !important;
}
}(function () {
// 기법: 좋아요 이전 개수와 이후 개수의 차이(diff)를 계산해, 자릿수가 바뀌는 칸만 그 방향으로
// 슬라이드시켜 "굴러가는" 시각을 만든다(249→250 이면 1의 자리 9→0, 10의 자리 4→5 가 동시에
// 굴러 올림수 전파를 보여준다). 버튼·하트 아이콘의 탄성 튐 폭도 diff 크기로 계산한다.
var root = document.getElementById('lkRoot');
var row = document.getElementById('lkRow');
var btn = document.getElementById('lkBtn');
var heart = document.getElementById('lkHeart');
var countWrap = document.getElementById('lkCount');
var status = document.getElementById('lkStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var count = 249;
var liked = false;
function rollDigit(col, newCh, diff, animate, delayMs) {
var track = col.querySelector('.lk__digitTrack');
var cur = track.querySelector('.lk__digit');
if (cur.textContent === newCh) return;
var next = document.createElement('span');
next.className = 'lk__digit';
next.textContent = newCh;
next.style.transform = 'translateY(' + (diff > 0 ? '100%' : '-100%') + ')';
track.appendChild(next);
if (!animate || reduce) {
cur.remove();
next.style.transform = 'translateY(0)';
return;
}
var dur = 260 + Math.min(200, Math.abs(diff) * 80);
cur.animate(
[{ transform: 'translateY(0)' }, { transform: 'translateY(' + (diff > 0 ? '-100%' : '100%') + ')' }],
{ duration: dur, delay: delayMs || 0, easing: 'cubic-bezier(.3,.7,.2,1)', fill: 'forwards' }
);
var anim = next.animate(
[{ transform: 'translateY(' + (diff > 0 ? '100%' : '-100%') + ')' }, { transform: 'translateY(0)' }],
{ duration: dur, delay: delayMs || 0, easing: 'cubic-bezier(.3,.7,.2,1)', fill: 'forwards' }
);
anim.onfinish = function () {
if (cur.parentNode) cur.remove();
next.style.transform = 'translateY(0)';
};
}
function renderCount(newCount, animate, delayMs) {
var diff = newCount - count;
if (diff === 0) return;
var oldStr = String(count).padStart(3, '0');
var newStr = String(newCount).padStart(3, '0');
var cols = countWrap.querySelectorAll('.lk__digitCol');
for (var i = 0; i < cols.length; i += 1) {
if (oldStr[i] === newStr[i]) continue;
rollDigit(cols[i], newStr[i], diff, animate, delayMs);
}
count = newCount;
}
function pop(el, diff, animate, delayMs) {
if (!animate || reduce) return;
var amp = 1.14 + Math.min(0.26, Math.abs(diff) * 0.04);
el.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(' + amp + ')' },
{ transform: 'scale(0.94)' },
{ transform: 'scale(1.03)' },
{ transform: 'scale(1)' }
],
{ duration: 420, delay: delayMs || 0, easing: 'ease-out' }
);
}
function rowPulse(diff, animate, delayMs) {
// 계산: 좋아요 차이(diff)의 크기만큼 행 전체 배경이 밝아졌다 가라앉는 폭을 넓혀
// 버튼 주변뿐 아니라 행 전체로 값을 전파한다(넓은 면적의 근거 있는 변화).
if (!animate || reduce) return;
var amp = Math.min(0.22, 0.1 + Math.abs(diff) * 0.04);
row.animate(
[
{ backgroundColor: 'rgba(255,158,187,0)' },
{ backgroundColor: 'rgba(255,158,187,' + amp + ')' },
{ backgroundColor: 'rgba(255,158,187,0)' }
],
{ duration: 640, delay: delayMs || 0, easing: 'ease-out' }
);
}
function setLiked(next, animate, delayMs) {
liked = next;
var diff = liked ? 1 : -1;
btn.setAttribute('aria-pressed', String(liked));
btn.classList.toggle('is-liked', liked);
var nextCount = count + diff;
status.textContent = liked
? '좋아요를 눌렀습니다. 좋아요 ' + nextCount + '개'
: '좋아요를 취소했습니다. 좋아요 ' + nextCount + '개';
renderCount(nextCount, animate, delayMs);
pop(btn, diff, animate, delayMs);
pop(heart, diff, animate, delayMs);
rowPulse(diff, animate, delayMs);
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
setLiked(!liked, true, 0);
});
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); }
});
if (reduce) {
// 초기 마크업이 이미 최종 상태(249, 좋아요 전)이므로 별도 처리 없음.
} else {
setLiked(true, true, 220);
window.setTimeout(function () {
if (root.classList.contains('is-demo')) setLiked(false, true, 0);
}, 1100);
window.setInterval(function () {
if (!root.classList.contains('is-demo')) return;
setLiked(!liked, true, 0);
}, 2200);
}
})();import { useEffect, useRef } from "react";
import styles from "./CommentThreadLikeCountElasticPop.module.scss";
// 기법: 좋아요 이전 개수와 이후 개수의 차이(diff)를 계산해, 자릿수가 바뀌는 칸만 그 방향으로
// 슬라이드시켜 "굴러가는" 시각을 만든다(249→250 이면 1의 자리 9→0, 10의 자리 4→5 가 동시에
// 굴러 올림수 전파를 보여준다). 버튼·하트 아이콘의 탄성 튐 폭도 diff 크기로 계산한다.
const INITIAL_COUNT = 249;
export default function CommentThreadLikeCountElasticPop() {
const rootRef = useRef<HTMLDivElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const heartRef = useRef<SVGSVGElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const row = rowRef.current;
const btn = btnRef.current;
const heart = heartRef.current;
const countWrap = countRef.current;
const status = statusRef.current;
if (!root || !row || !btn || !heart || !countWrap || !status) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let count = INITIAL_COUNT;
let liked = false;
function rollDigit(col: Element, newCh: string, diff: number, animate: boolean, delayMs: number) {
const track = col.querySelector(":scope > .lk__digitTrack");
const cur = track?.querySelector(":scope > .lk__digit");
if (!track || !cur) return;
if (cur.textContent === newCh) return;
const next = document.createElement("span");
next.className = "lk__digit";
next.textContent = newCh;
next.style.transform = `translateY(${diff > 0 ? "100%" : "-100%"})`;
track.appendChild(next);
if (!animate || reduce) {
cur.remove();
next.style.transform = "translateY(0)";
return;
}
const dur = 260 + Math.min(200, Math.abs(diff) * 80);
cur.animate(
[{ transform: "translateY(0)" }, { transform: `translateY(${diff > 0 ? "-100%" : "100%"})` }],
{ duration: dur, delay: delayMs, easing: "cubic-bezier(.3,.7,.2,1)", fill: "forwards" }
);
const anim = next.animate(
[{ transform: `translateY(${diff > 0 ? "100%" : "-100%"})` }, { transform: "translateY(0)" }],
{ duration: dur, delay: delayMs, easing: "cubic-bezier(.3,.7,.2,1)", fill: "forwards" }
);
anim.onfinish = () => {
if (cur.parentNode) cur.remove();
next.style.transform = "translateY(0)";
};
}
function renderCount(newCount: number, animate: boolean, delayMs: number) {
const diff = newCount - count;
if (diff === 0) return;
const oldStr = String(count).padStart(3, "0");
const newStr = String(newCount).padStart(3, "0");
const cols = countWrap!.querySelectorAll(":scope > .lk__digitCol");
cols.forEach((col, i) => {
if (oldStr[i] === newStr[i]) return;
rollDigit(col, newStr[i], diff, animate, delayMs);
});
count = newCount;
}
function pop(el: Element, diff: number, animate: boolean, delayMs: number) {
if (!animate || reduce) return;
const amp = 1.14 + Math.min(0.26, Math.abs(diff) * 0.04);
el.animate(
[
{ transform: "scale(1)" },
{ transform: `scale(${amp})` },
{ transform: "scale(0.94)" },
{ transform: "scale(1.03)" },
{ transform: "scale(1)" },
],
{ duration: 420, delay: delayMs, easing: "ease-out" }
);
}
function rowPulse(diff: number, animate: boolean, delayMs: number) {
// 계산: 좋아요 차이(diff)의 크기만큼 행 전체 배경이 밝아졌다 가라앉는 폭을 넓혀
// 버튼 주변뿐 아니라 행 전체로 값을 전파한다(넓은 면적의 근거 있는 변화).
if (!animate || reduce) return;
const amp = Math.min(0.22, 0.1 + Math.abs(diff) * 0.04);
row!.animate(
[
{ backgroundColor: "rgba(255,158,187,0)" },
{ backgroundColor: `rgba(255,158,187,${amp})` },
{ backgroundColor: "rgba(255,158,187,0)" },
],
{ duration: 640, delay: delayMs, easing: "ease-out" }
);
}
function setLiked(next: boolean, animate: boolean, delayMs: number) {
liked = next;
const diff = liked ? 1 : -1;
btn!.setAttribute("aria-pressed", String(liked));
btn!.classList.toggle("is-liked", liked);
const nextCount = count + diff;
status!.textContent = liked
? `좋아요를 눌렀습니다. 좋아요 ${nextCount}개`
: `좋아요를 취소했습니다. 좋아요 ${nextCount}개`;
renderCount(nextCount, animate, delayMs);
pop(btn!, diff, animate, delayMs);
pop(heart!, diff, animate, delayMs);
rowPulse(diff, animate, delayMs);
}
const onActivate = () => { root.classList.remove("is-demo"); setLiked(!liked, true, 0); };
btn.addEventListener("click", onActivate);
btn.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onActivate(); }
});
let timer: number | undefined;
let popTimer: number | undefined;
if (reduce) {
// 초기 마크업이 이미 최종 상태(249, 좋아요 전)이므로 별도 처리 없음.
} else {
setLiked(true, true, 220);
popTimer = window.setTimeout(() => {
if (root.classList.contains("is-demo")) setLiked(false, true, 0);
}, 1100);
timer = window.setInterval(() => {
if (!root.classList.contains("is-demo")) return;
setLiked(!liked, true, 0);
}, 2200);
}
return () => { if (timer) window.clearInterval(timer); if (popTimer) window.clearTimeout(popTimer); };
}, []);
const digits = String(INITIAL_COUNT).padStart(3, "0").split("");
return (
<main className={styles.stage}>
<div className={`${styles.lk} is-demo`} ref={rootRef}>
<div className={styles.lk__row} ref={rowRef}>
<span className={styles.lk__avatar} aria-hidden="true" />
<div className={styles.lk__body}>
<p className={styles.lk__meta}>
<strong className={styles.lk__name}>민트파도</strong>
<span className={styles.lk__time}>14분 전</span>
</p>
<p className={styles.lk__text}>이 방법 진짜 깔끔하네요, 바로 적용했어요.</p>
<button type="button" className={`lk__likeBtn ${styles.lk__likeBtn}`} ref={btnRef} aria-pressed="false" aria-label="좋아요">
<svg className={`lk__heart ${styles.lk__heart}`} ref={heartRef} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" />
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
</svg>
<span className={`lk__count ${styles.lk__count}`} ref={countRef} aria-hidden="true">
{digits.map((d, i) => (
<span key={i} className={`lk__digitCol ${styles.lk__digitCol}`}>
<span className={`lk__digitTrack ${styles.lk__digitTrack}`}>
<span className={`lk__digit ${styles.lk__digit}`}>{d}</span>
</span>
</span>
))}
</span>
</button>
</div>
</div>
<p className={styles.lk__sr} ref={statusRef} aria-live="polite" />
</div>
</main>
);
}04 이전 값과의 차이를 굴려 올리는 좋아요 카운트 팝 — 측정 누적면적 24.637% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록
이 데모가 속한 글
댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록댓글 ui는 대댓글이 몇 단계 깊이인지, 지워진 칸의 높이가 얼마인지, 고정된 뒤 얼마나 시간이 지났는지를 계산해 화면에 반영하는 댓글 목록이다. 지워진 댓글 칸이 차지했던 픽셀 값을 그대로 이어받아 아래 댓글들이 이 글의 데모 전부 보기


