05틱톡 임베드 목업 — 경과 시간만큼 좋아요 숫자를 다시 구하는 카드
카드가 나타난 뒤 지난 시간을 일정 간격으로 재서, 그 간격마다 좋아요 숫자를 조금씩 더한 값으로 다시 계산해 표시하는 세로 영상 목업 카드입니다.
쓰임인플루언서 소개 페이지, 콘텐츠 큐레이션 목록
writing-modeobject-fitwhite-space

<main class="stage">
<div class="card">
<div class="left">
<div class="thumbWrap" id="thumbWrap">
<canvas class="thumb" id="thumb" width="132" height="176"></canvas>
<div class="flash" id="flash" aria-hidden="true"></div>
<div class="burst" id="burst" aria-hidden="true">
<svg class="burstIcon" viewBox="0 0 256 256"><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>
</div>
</div>
<p class="caption">@channelA · 오리지널 사운드</p>
</div>
<div class="rail">
<div class="heartWrap" id="heartWrap">
<svg class="heartIcon" 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>
</div>
<span class="likeCount" id="likeCount">1,247</span>
<span class="railLabel">좋아요</span>
</div>
</div>
<div class="readout" id="readout">간격 0.4초 · 좋아요 1,247</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세로형 카드 임베드 — 좋아요 계단 카운터(계산 겹)와 하트 팝은 JS 계산 겹의 재료.
// CSS keyframes 는 두지 않는다 — 전부 rAF 실시간 루프.
@include stage($stage-ink);
.stage {
overflow: hidden;
}
.card {
display: flex;
align-items: stretch;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
--thumb-w: 120px;
}
.left {
display: flex;
flex-direction: column;
gap: $sp-2;
min-width: 0;
width: var(--thumb-w, 120px);
flex-shrink: 0;
}
.thumbWrap {
position: relative;
width: var(--thumb-w, 120px);
aspect-ratio: 3 / 4;
border-radius: $r-sm;
overflow: hidden;
}
.thumb {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.flash {
position: absolute;
inset: 0;
background: $subject-cream;
opacity: 0;
pointer-events: none;
}
.burst {
position: absolute;
top: 50%;
left: 50%;
width: 46px;
height: 46px;
color: $subject-cream;
opacity: 0;
pointer-events: none;
}
.burstIcon {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 2px 6px rgba(23, 20, 26, .5));
}
.caption {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
color: $subject-ink;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
width: 40px;
flex-shrink: 0;
}
.heartWrap {
width: 24px;
height: 24px;
display: block;
}
.heartIcon {
width: 100%;
height: 100%;
display: block;
}
.heartIcon path {
fill: $subject-pink;
}
.likeCount {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
color: $subject-ink;
}
.railLabel {
writing-mode: vertical-rl;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1;
color: $subject-ink;
opacity: .72;
}
.readout {
position: relative;
margin-top: $sp-3;
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
}
@media (max-width: 340px) {
.card {
padding: $sp-2;
gap: $sp-2;
--thumb-w: 56px;
}
.rail {
width: 30px;
}
.heartWrap {
width: 20px;
height: 20px;
}
.caption {
font-size: 10px;
}
.likeCount {
font-size: 10px;
}
.railLabel {
font-size: 8px;
}
.readout {
font-size: 10px;
padding: $sp-1 $sp-2;
margin-top: $sp-2;
}
}
@media (prefers-reduced-motion: reduce) {
.burst,
.flash {
opacity: 0;
}
}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);
}
.stage {
overflow: hidden;
}
.card {
display: flex;
align-items: stretch;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
--thumb-w: 120px;
}
.left {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
width: var(--thumb-w, 120px);
flex-shrink: 0;
}
.thumbWrap {
position: relative;
width: var(--thumb-w, 120px);
aspect-ratio: 3/4;
border-radius: 8px;
overflow: hidden;
}
.thumb {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.flash {
position: absolute;
inset: 0;
background: #fff7e6;
opacity: 0;
pointer-events: none;
}
.burst {
position: absolute;
top: 50%;
left: 50%;
width: 46px;
height: 46px;
color: #fff7e6;
opacity: 0;
pointer-events: none;
}
.burstIcon {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 2px 6px rgba(23, 20, 26, 0.5));
}
.caption {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
color: #17141a;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 40px;
flex-shrink: 0;
}
.heartWrap {
width: 24px;
height: 24px;
display: block;
}
.heartIcon {
width: 100%;
height: 100%;
display: block;
}
.heartIcon path {
fill: #ff9ebb;
}
.likeCount {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
color: #17141a;
}
.railLabel {
writing-mode: vertical-rl;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1;
color: #17141a;
opacity: 0.72;
}
.readout {
position: relative;
margin-top: 12px;
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
}
@media (max-width: 340px) {
.card {
padding: 8px;
gap: 8px;
--thumb-w: 56px;
}
.rail {
width: 30px;
}
.heartWrap {
width: 20px;
height: 20px;
}
.caption {
font-size: 10px;
}
.likeCount {
font-size: 10px;
}
.railLabel {
font-size: 8px;
}
.readout {
font-size: 10px;
padding: 4px 8px;
margin-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.burst,
.flash {
opacity: 0;
}
}// 세로형 카드 임베드(05) — 좋아요는 상수 간격마다 계단으로 오르고, 오른 순간 하트가 튕긴다.
// 엄지가 두 번 두드릴 때 나오는 큰 하트도 같은 간격으로 썸네일 위에서 커지며 사라진다.
// 실키·실서버 요청 없음 — 좌표·수치는 고정 시나리오로 자동 재생한다.
(function () {
function mulberry32(seed) {
var a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas, seed) {
var ctx = canvas.getContext('2d');
if (!ctx) return;
var rnd = mulberry32(seed);
var w = canvas.width, h = canvas.height;
var hues = [216, 32, 268, 168];
ctx.fillStyle = 'hsl(' + hues[Math.floor(rnd() * hues.length)] + ', 38%, 22%)';
ctx.fillRect(0, 0, w, h);
for (var i = 0; i < 6; i++) {
var cx = rnd() * w, cy = rnd() * h, r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle = 'hsla(' + hues[Math.floor(rnd() * hues.length)] + ', ' + (44 + rnd() * 22).toFixed(1) + '%, ' + (38 + rnd() * 26).toFixed(1) + '%, .5)';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = 'rgba(255, 247, 230, .06)';
for (var j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
var thumbEl = document.getElementById('thumb');
var flashEl = document.getElementById('flash');
var burstEl = document.getElementById('burst');
var heartWrapEl = document.getElementById('heartWrap');
var likeCountEl = document.getElementById('likeCount');
var readoutEl = document.getElementById('readout');
if (!thumbEl || !flashEl || !burstEl || !heartWrapEl || !likeCountEl || !readoutEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
drawSeedThumb(thumbEl, 105);
var CYCLE_MS = 2000;
var LIKE_INTERVAL = 400;
var LIKE_STEP = 3;
var LIKE_BASE = 1247;
function apply(t) {
// 계산 겹: 간격마다 좋아요 수를 다시 계산 — 트윈이 아니라 계단식 카운터
var likes = LIKE_BASE + Math.floor(t / LIKE_INTERVAL) * LIKE_STEP;
var since = t % LIKE_INTERVAL;
var pop = since < 180 ? 1 + 0.28 * (1 - since / 180) : 1;
heartWrapEl.style.transform = 'scale(' + pop.toFixed(3) + ')';
likeCountEl.textContent = likes.toLocaleString('en-US');
readoutEl.textContent = '간격 ' + (LIKE_INTERVAL / 1000).toFixed(1) + '초 · 좋아요 ' + likes.toLocaleString('en-US');
// 두 번 두드림 하트 — 같은 간격에서 0→1로 커지며 옅어진다(썸네일 위 큰 겹)
var prog = since / LIKE_INTERVAL;
var burstScale = 0.3 + prog * 2.3;
var burstOpacity = Math.max(0, 0.85 * (1 - prog * 1.15));
burstEl.style.transform = 'translate(-50%, -50%) scale(' + burstScale.toFixed(3) + ')';
burstEl.style.opacity = burstOpacity.toFixed(3);
// 좋아요가 오르는 순간 썸네일 전체가 짧게 밝아진다(플래시) — 하트 겹과 같은 since 를 공유
var flashOpacity = since < 160 ? 0.4 * (1 - since / 160) : 0;
flashEl.style.opacity = flashOpacity.toFixed(3);
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedTiktok.module.scss";
// 세로형 카드 임베드 — 좋아요는 상수 간격마다 계단으로 오르고, 오른 순간 하트가 튕긴다.
// 두 번 두드림 하트도 같은 간격으로 썸네일 위에서 커지며 사라진다. 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
const LIKE_INTERVAL = 400;
const LIKE_STEP = 3;
const LIKE_BASE = 1247;
function mulberry32(seed: number) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas: HTMLCanvasElement, seed: number) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rnd = mulberry32(seed);
const w = canvas.width;
const h = canvas.height;
const hues = [216, 32, 268, 168];
ctx.fillStyle = "hsl(" + hues[Math.floor(rnd() * hues.length)] + ", 38%, 22%)";
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 6; i++) {
const cx = rnd() * w;
const cy = rnd() * h;
const r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle =
"hsla(" + hues[Math.floor(rnd() * hues.length)] + ", " + (44 + rnd() * 22).toFixed(1) + "%, " + (38 + rnd() * 26).toFixed(1) + "%, .5)";
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "rgba(255, 247, 230, .06)";
for (let j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
export default function EmbedTiktok() {
const thumbRef = useRef<HTMLCanvasElement>(null);
const flashRef = useRef<HTMLDivElement>(null);
const burstRef = useRef<HTMLDivElement>(null);
const heartWrapRef = useRef<HTMLDivElement>(null);
const likeCountRef = useRef<HTMLSpanElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const thumbEl = thumbRef.current;
const flashEl = flashRef.current;
const burstEl = burstRef.current;
const heartWrapEl = heartWrapRef.current;
const likeCountEl = likeCountRef.current;
const readoutEl = readoutRef.current;
if (!thumbEl || !flashEl || !burstEl || !heartWrapEl || !likeCountEl || !readoutEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
drawSeedThumb(thumbEl, 105);
function apply(t: number) {
// 계산 겹: 간격마다 좋아요 수를 다시 계산 — 트윈이 아니라 계단식 카운터
const likes = LIKE_BASE + Math.floor(t / LIKE_INTERVAL) * LIKE_STEP;
const since = t % LIKE_INTERVAL;
const pop = since < 180 ? 1 + 0.28 * (1 - since / 180) : 1;
heartWrapEl!.style.transform = "scale(" + pop.toFixed(3) + ")";
likeCountEl!.textContent = likes.toLocaleString("en-US");
readoutEl!.textContent = "간격 " + (LIKE_INTERVAL / 1000).toFixed(1) + "초 · 좋아요 " + likes.toLocaleString("en-US");
// 두 번 두드림 하트 — 같은 간격에서 0→1로 커지며 옅어진다(썸네일 위 큰 겹)
const prog = since / LIKE_INTERVAL;
const burstScale = 0.3 + prog * 2.3;
const burstOpacity = Math.max(0, 0.85 * (1 - prog * 1.15));
burstEl!.style.transform = "translate(-50%, -50%) scale(" + burstScale.toFixed(3) + ")";
burstEl!.style.opacity = burstOpacity.toFixed(3);
// 좋아요가 오르는 순간 썸네일 전체가 짧게 밝아진다(플래시) — 하트 겹과 같은 since 를 공유
const flashOpacity = since < 160 ? 0.4 * (1 - since / 160) : 0;
flashEl!.style.opacity = flashOpacity.toFixed(3);
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<div className={styles.card}>
<div className={styles.left}>
<div className={styles.thumbWrap}>
<canvas className={styles.thumb} ref={thumbRef} width={132} height={176} />
<div className={styles.flash} ref={flashRef} aria-hidden="true" />
<div className={styles.burst} ref={burstRef} aria-hidden="true">
<svg className={styles.burstIcon} viewBox="0 0 256 256">
<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>
</div>
</div>
<p className={styles.caption}>@channelA · 오리지널 사운드</p>
</div>
<div className={styles.rail}>
<div className={styles.heartWrap} ref={heartWrapRef}>
<svg className={styles.heartIcon} 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>
</div>
<span className={styles.likeCount} ref={likeCountRef}>
1,247
</span>
<span className={styles.railLabel}>좋아요</span>
</div>
</div>
<div className={styles.readout} ref={readoutRef}>
간격 0.4초 · 좋아요 1,247
</div>
</div>
);
}05 틱톡 임베드 목업 — 경과 시간만큼 좋아요 숫자를 다시 구하는 카드 — 측정 누적면적 13.598% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 유튜브 임베드 코드 9종 — SNS 카드도 담은 플레이스홀더 UI
이 데모가 속한 글
유튜브 임베드 코드 9종 — SNS 카드도 담은 플레이스홀더 UI프레임 폭에 맞춰 세로 비율이 다시 계산되고, 화면에 머무른 시간이 일정 값을 넘으면 가벼운 카드가 스스로 완전한 프레임으로 바뀌고, 최근 소셜 임베드는 등장 뒤 지난 시간만큼 배지 숫자가 조금씩 올라가는 임베드 U 이 글의 데모 전부 보기


