06고정 뒤 지난 시간만큼 옅어지는 고정 댓글 강조
댓글이 고정된 시점부터 지난 시간을 계산해 강조 빛의 밝기가 그 시간에 비례해 옅어지고, 일정 시간이 지나면 은은한 색으로 고정되어 더는 변하지 않는다.
쓰임작성자 고정 댓글 표시
DECAY_DURATION_MSpin__decayFilldecayAt

<main class="stage">
<div class="pin is-demo" id="pinRoot">
<div class="pin__list" role="list">
<div class="pin__item is-pinned" id="pinItem">
<svg class="pin__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.66 98.34l-57.27 57.46c11.46 22.93-1.72 45.86-10.11 57a8 8 0 0 1-12 .83L42.34 105.76A8 8 0 0 1 43 93.85c29.65-23.92 57.4-10 57.4-10l57.27-57.46a8 8 0 0 1 11.31 0L229.66 87a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m235.32 81.37l-60.69-60.68a16 16 0 0 0-22.63 0l-53.63 53.8c-10.66-3.34-35-7.37-60.4 13.14a16 16 0 0 0-1.29 23.78L85 159.71l-42.66 42.63a8 8 0 0 0 11.32 11.32L96.29 171l48.29 48.29A16 16 0 0 0 155.9 224h1.13a15.93 15.93 0 0 0 11.64-6.33c19.64-26.1 17.75-47.32 13.19-60L235.33 104a16 16 0 0 0-.01-22.63M224 92.69l-57.27 57.46a8 8 0 0 0-1.49 9.22c9.46 18.93-1.8 38.59-9.34 48.62L48 100.08c12.08-9.74 23.64-12.31 32.48-12.31A40.1 40.1 0 0 1 96.81 91a8 8 0 0 0 9.25-1.51L163.32 32L224 92.68Z"/></g></svg>
<span class="pin__avatar" aria-hidden="true"></span>
<div class="pin__body">
<p class="pin__meta"><strong class="pin__name">호수나그네</strong><span class="pin__time">고정됨</span></p>
<p class="pin__text">자주 묻는 질문은 이 댓글을 확인해 주세요.</p>
</div>
<span class="pin__decayBar"><i class="pin__decayFill" id="pinFill"></i></span>
</div>
<div class="pin__item">
<span class="pin__avatar" aria-hidden="true"></span>
<div class="pin__body">
<p class="pin__meta"><strong class="pin__name">야생화다발</strong><span class="pin__time">52분 전</span></p>
<p class="pin__text">고정 댓글 색이 서서히 변하는 게 신기해요.</p>
</div>
</div>
<div class="pin__item">
<span class="pin__avatar" aria-hidden="true"></span>
<div class="pin__body">
<p class="pin__meta"><strong class="pin__name">별헤는밤</strong><span class="pin__time">2시간 전</span></p>
<p class="pin__text">시간이 지나면 은은하게 자리 잡는군요.</p>
</div>
</div>
</div>
<p class="pin__sr" id="pinStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pin {
--pin-glow: 0.55;
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;
}
.pin__list {
display: flex;
flex-direction: column;
gap: $sp-2;
height: 100%;
}
.pin__item {
position: relative;
display: flex;
align-items: flex-start;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .05);
border-radius: $r-sm;
overflow: hidden;
flex: 1;
min-height: 0;
&.is-pinned {
background: rgba(94, 234, 212, var(--pin-glow));
background-blend-mode: soft-light;
border: 1px solid rgba(107, 232, 190, calc(var(--pin-glow) * 0.9));
}
}
.pin__icon {
flex: none;
width: 15px;
height: 15px;
color: $subject-mint;
margin-top: 4px;
}
.pin__avatar {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(160deg, $subject-mint, $subject-sky);
}
.pin__item.is-pinned .pin__avatar {
box-shadow: 0 0 calc(var(--pin-glow) * 22px) calc(var(--pin-glow) * 6px) rgba(107, 232, 190, .8);
}
.pin__body { min-width: 0; flex: 1; }
.pin__meta { margin: 0 0 4px; display: flex; align-items: baseline; gap: $sp-2; }
.pin__name { color: #fff; font-size: 12px; font-weight: 700; }
.pin__time { color: rgba(255, 255, 255, .5); font-size: 10.5px; }
.pin__item.is-pinned .pin__time { color: $subject-mint; }
.pin__text { margin: 0; color: rgba(255, 255, 255, .86); font-size: 12px; line-height: 1.5; }
.pin__decayBar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: rgba(255, 255, 255, .1);
border-radius: $r-pill 0 0 $r-pill;
}
.pin__decayFill {
display: block;
width: 100%;
height: 100%;
background: $subject-mint;
transform-origin: top;
transform: scaleY(0);
border-radius: inherit;
}
.pin__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.pin { padding: $sp-3; }
.pin__text, .pin__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.pin__decayFill { 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);
}
.pin {
--pin-glow: 0.55;
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;
}
.pin__list {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
.pin__item {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
overflow: hidden;
flex: 1;
min-height: 0;
}
.pin__item.is-pinned {
background: rgba(94, 234, 212, var(--pin-glow));
background-blend-mode: soft-light;
border: 1px solid rgba(107, 232, 190, calc(var(--pin-glow) * 0.9));
}
.pin__icon {
flex: none;
width: 15px;
height: 15px;
color: #4cd4a6;
margin-top: 3px;
}
.pin__avatar {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(160deg, #4cd4a6, #6ec8ff);
}
.pin__item.is-pinned .pin__avatar {
box-shadow: 0 0 calc(var(--pin-glow) * 22px) calc(var(--pin-glow) * 6px) rgba(107, 232, 190, 0.8);
}
.pin__body {
min-width: 0;
flex: 1;
}
.pin__meta {
margin: 0 0 2px;
display: flex;
align-items: baseline;
gap: 8px;
}
.pin__name {
color: #fff;
font-size: 12px;
font-weight: 700;
}
.pin__time {
color: rgba(255, 255, 255, 0.5);
font-size: 10.5px;
}
.pin__item.is-pinned .pin__time {
color: #4cd4a6;
}
.pin__text {
margin: 0;
color: rgba(255, 255, 255, 0.86);
font-size: 12px;
line-height: 1.5;
}
.pin__decayBar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 999px 0 0 999px;
}
.pin__decayFill {
display: block;
width: 100%;
height: 100%;
background: #4cd4a6;
transform-origin: top;
transform: scaleY(0);
border-radius: inherit;
}
.pin__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.pin {
padding: 12px;
}
.pin__text, .pin__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.pin__decayFill {
transition: none !important;
}
}(function () {
// 기법: performance.now() 기반 가상 시계(Date 미사용). 핀이 걸린 순간부터 경과 시간을
// DECAY_DURATION_MS 까지 비례 계산해 강조 밝기·좌측 두께 바를 줄이고, 그 시간을 넘으면
// SETTLED_TINT 로 확정해 루프를 멈춘다(더는 변하지 않음). 가상 시계는 VIRTUAL_CLOCK_SCALE
// 로 실제 1초를 데모용 1초당 더 빠르게 재생한다.
var DECAY_DURATION_MS = 1400;
var VIRTUAL_CLOCK_SCALE = 1;
var START_DELAY_MS = 220;
var PEAK_GLOW = 0.55;
var SETTLED_GLOW = 0.12;
var root = document.getElementById('pinRoot');
var item = document.getElementById('pinItem');
var fill = document.getElementById('pinFill');
var status = document.getElementById('pinStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function decayAt(elapsedMs) {
if (elapsedMs <= 0) return PEAK_GLOW;
if (elapsedMs >= DECAY_DURATION_MS) return SETTLED_GLOW;
var t = elapsedMs / DECAY_DURATION_MS;
// 감쇠 곡선: 빠르게 시작해 천천히 수렴(지수 감쇠를 0..1 구간으로 정규화).
var eased = 1 - Math.exp(-4.2 * t) / (1 - Math.exp(-4.2));
return PEAK_GLOW + (SETTLED_GLOW - PEAK_GLOW) * eased;
}
function applyDecay(glow, progress) {
item.style.setProperty('--pin-glow', String(glow));
fill.style.transform = 'scaleX(' + Math.max(0, progress) + ')';
}
var rafId = 0;
var start = 0;
function step(now) {
if (!start) start = now;
var elapsedVirtual = (now - start) * VIRTUAL_CLOCK_SCALE;
var elapsed = elapsedVirtual - START_DELAY_MS;
if (elapsed < 0) elapsed = 0;
applyDecay(decayAt(elapsed), elapsed / DECAY_DURATION_MS);
if (elapsed >= DECAY_DURATION_MS) {
applyDecay(SETTLED_GLOW, 1);
status.textContent = '고정 강조가 은은한 색으로 자리 잡았습니다.';
return; // 확정 — 더는 변하지 않음
}
rafId = window.requestAnimationFrame(step);
}
if (reduce) {
applyDecay(SETTLED_GLOW, 1);
status.textContent = '고정 강조가 은은한 색으로 자리 잡았습니다.';
} else {
rafId = window.requestAnimationFrame(step);
}
})();import { useEffect, useRef } from "react";
import styles from "./CommentThreadPinnedHighlightDecay.module.scss";
// 기법: performance.now() 기반 가상 시계(Date 미사용). 핀이 걸린 순간부터 경과 시간을
// DECAY_DURATION_MS 까지 비례 계산해 강조 밝기·좌측 두께 바를 줄이고, 그 시간을 넘으면
// SETTLED_TINT 로 확정해 루프를 멈춘다(더는 변하지 않음).
const DECAY_DURATION_MS = 1400;
const VIRTUAL_CLOCK_SCALE = 1;
const START_DELAY_MS = 220;
const PEAK_GLOW = 0.55;
const SETTLED_GLOW = 0.12;
export default function CommentThreadPinnedHighlightDecay() {
const rootRef = useRef<HTMLDivElement>(null);
const itemRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLSpanElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const item = itemRef.current;
const fill = fillRef.current;
const status = statusRef.current;
if (!root || !item || !fill || !status) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
function decayAt(elapsedMs: number): number {
if (elapsedMs <= 0) return PEAK_GLOW;
if (elapsedMs >= DECAY_DURATION_MS) return SETTLED_GLOW;
const t = elapsedMs / DECAY_DURATION_MS;
const eased = 1 - Math.exp(-4.2 * t) / (1 - Math.exp(-4.2));
return PEAK_GLOW + (SETTLED_GLOW - PEAK_GLOW) * eased;
}
function applyDecay(glow: number, progress: number) {
item!.style.setProperty("--pin-glow", String(glow));
fill!.style.transform = `scaleY(${Math.max(0, progress)})`;
}
let rafId = 0;
let start = 0;
function step(now: number) {
if (!start) start = now;
const elapsedVirtual = (now - start) * VIRTUAL_CLOCK_SCALE;
let elapsed = elapsedVirtual - START_DELAY_MS;
if (elapsed < 0) elapsed = 0;
applyDecay(decayAt(elapsed), elapsed / DECAY_DURATION_MS);
if (elapsed >= DECAY_DURATION_MS) {
applyDecay(SETTLED_GLOW, 1);
status!.textContent = "고정 강조가 은은한 색으로 자리 잡았습니다.";
return;
}
rafId = window.requestAnimationFrame(step);
}
if (reduce) {
applyDecay(SETTLED_GLOW, 1);
status.textContent = "고정 강조가 은은한 색으로 자리 잡았습니다.";
} else {
rafId = window.requestAnimationFrame(step);
}
return () => window.cancelAnimationFrame(rafId);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pin} is-demo`} ref={rootRef}>
<div className={styles.pin__list} role="list">
<div className={`${styles.pin__item} ${styles["pin__item--pinned"]}`} ref={itemRef}>
<svg className={styles.pin__icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="m229.66 98.34l-57.27 57.46c11.46 22.93-1.72 45.86-10.11 57a8 8 0 0 1-12 .83L42.34 105.76A8 8 0 0 1 43 93.85c29.65-23.92 57.4-10 57.4-10l57.27-57.46a8 8 0 0 1 11.31 0L229.66 87a8 8 0 0 1 0 11.34" opacity=".2" />
<path d="m235.32 81.37l-60.69-60.68a16 16 0 0 0-22.63 0l-53.63 53.8c-10.66-3.34-35-7.37-60.4 13.14a16 16 0 0 0-1.29 23.78L85 159.71l-42.66 42.63a8 8 0 0 0 11.32 11.32L96.29 171l48.29 48.29A16 16 0 0 0 155.9 224h1.13a15.93 15.93 0 0 0 11.64-6.33c19.64-26.1 17.75-47.32 13.19-60L235.33 104a16 16 0 0 0-.01-22.63M224 92.69l-57.27 57.46a8 8 0 0 0-1.49 9.22c9.46 18.93-1.8 38.59-9.34 48.62L48 100.08c12.08-9.74 23.64-12.31 32.48-12.31A40.1 40.1 0 0 1 96.81 91a8 8 0 0 0 9.25-1.51L163.32 32L224 92.68Z" />
</g>
</svg>
<span className={styles.pin__avatar} aria-hidden="true" />
<div className={styles.pin__body}>
<p className={styles.pin__meta}>
<strong className={styles.pin__name}>호수나그네</strong>
<span className={styles.pin__time}>고정됨</span>
</p>
<p className={styles.pin__text}>자주 묻는 질문은 이 댓글을 확인해 주세요.</p>
</div>
<span className={styles.pin__decayBar}><i className={styles.pin__decayFill} ref={fillRef} /></span>
</div>
<div className={styles.pin__item}>
<span className={styles.pin__avatar} aria-hidden="true" />
<div className={styles.pin__body}>
<p className={styles.pin__meta}>
<strong className={styles.pin__name}>야생화다발</strong>
<span className={styles.pin__time}>52분 전</span>
</p>
<p className={styles.pin__text}>고정 댓글 색이 서서히 변하는 게 신기해요.</p>
</div>
</div>
<div className={styles.pin__item}>
<span className={styles.pin__avatar} aria-hidden="true" />
<div className={styles.pin__body}>
<p className={styles.pin__meta}>
<strong className={styles.pin__name}>별헤는밤</strong>
<span className={styles.pin__time}>2시간 전</span>
</p>
<p className={styles.pin__text}>시간이 지나면 은은하게 자리 잡는군요.</p>
</div>
</div>
</div>
<p className={styles.pin__sr} ref={statusRef} aria-live="polite" />
</div>
</main>
);
}06 고정 뒤 지난 시간만큼 옅어지는 고정 댓글 강조 — 측정 누적면적 19.131% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록
이 데모가 속한 글
댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록댓글 ui는 대댓글이 몇 단계 깊이인지, 지워진 칸의 높이가 얼마인지, 고정된 뒤 얼마나 시간이 지났는지를 계산해 화면에 반영하는 댓글 목록이다. 지워진 댓글 칸이 차지했던 픽셀 값을 그대로 이어받아 아래 댓글들이 이 글의 데모 전부 보기


