08最終訪問時刻と比較して位置を計算する未読区切り線
最終訪問時刻と各コメントの投稿時刻を比較して最初に現れる新着コメントの位置を計算し、その場所に区切り線が少し上から滑り降りて収まる。
使いどころ再訪問時の新着コメント表示
LAST_VISIT_MINUTES_AGOun__dividerfindUnreadIndex

<main class="stage">
<div class="un is-demo" id="unRoot">
<ul class="un__list" role="list" id="unList">
<li class="un__item" data-min-ago="180">既読コメント1 — 前回の訪問時に読みました。</li>
<li class="un__item" data-min-ago="120">既読コメント2 — これも前回見ました。</li>
<li class="un__item" data-min-ago="95">既読コメント3 — ここまでが前回の訪問範囲。</li>
<li class="un__item" data-min-ago="40">新着コメント1 — 最後の訪問後に投稿されました。</li>
<li class="un__item" data-min-ago="12">新着コメント2 — 区切り線の下はすべて新着です。</li>
</ul>
<p class="un__sr" id="unStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.un {
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;
}
.un__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
height: 100%;
justify-content: center;
}
.un__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .04);
border-radius: $r-sm;
color: rgba(255, 255, 255, .72);
font-size: 12px;
line-height: 1.45;
&.is-new {
background: rgba(94, 152, 246, .12);
border: 1px solid rgba(91, 140, 248, .35);
color: rgba(255, 255, 255, .92);
}
&.is-new::before {
content: '';
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: $subject-blue-light;
}
}
.un__divider {
display: flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-3;
color: $subject-blue-light;
font-size: 10.5px;
font-weight: 700;
letter-spacing: .04em;
opacity: 0;
will-change: transform;
&::before {
content: '';
flex: 1;
height: 1.5px;
background: linear-gradient(90deg, rgba(91, 140, 248, 0), rgba(91, 140, 248, .8));
}
&::after {
content: '';
flex: 1;
height: 1.5px;
background: linear-gradient(90deg, rgba(91, 140, 248, .8), rgba(91, 140, 248, 0));
}
}
.un__dividerLabel { white-space: nowrap; }
.un__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.un { padding: $sp-3; }
.un__item { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.un__divider { 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);
}
.un {
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;
}
.un__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
justify-content: center;
}
.un__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
line-height: 1.45;
}
.un__item.is-new {
background: rgba(94, 152, 246, 0.12);
border: 1px solid rgba(91, 140, 248, 0.35);
color: rgba(255, 255, 255, 0.92);
}
.un__item.is-new::before {
content: "";
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: #5b8cf8;
}
.un__divider {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
color: #5b8cf8;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
opacity: 0;
will-change: transform;
}
.un__divider::before {
content: "";
flex: 1;
height: 1.5px;
background: linear-gradient(90deg, rgba(91, 140, 248, 0), rgba(91, 140, 248, 0.8));
}
.un__divider::after {
content: "";
flex: 1;
height: 1.5px;
background: linear-gradient(90deg, rgba(91, 140, 248, 0.8), rgba(91, 140, 248, 0));
}
.un__dividerLabel {
white-space: nowrap;
}
.un__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.un {
padding: 12px;
}
.un__item {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.un__divider {
transition: none !important;
}
}(function () {
// 기법: 각 댓글의 "몇 분 전 작성" 값(가짜 상대값, Date 미사용)과 LAST_VISIT_MINUTES_AGO
// (마지막 방문이 몇 분 전인지)를 비교해, 방문 이후 쓰인 첫 댓글의 위치를 계산한다.
// minAgo < LAST_VISIT_MINUTES_AGO 인 첫 행 앞에 구분선을 삽입하고 위에서 살짝 내려와 멎는다.
var LAST_VISIT_MINUTES_AGO = 90;
var NEW_ROW_HILITE_STAGGER_MS = 90;
var root = document.getElementById('unRoot');
var list = document.getElementById('unList');
var status = document.getElementById('unStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function minAgoOf(item) {
return parseInt(item.getAttribute('data-min-ago'), 10);
}
function findUnreadIndex() {
var items = list.querySelectorAll('.un__item');
for (var i = 0; i < items.length; i += 1) {
if (minAgoOf(items[i]) < LAST_VISIT_MINUTES_AGO) return i;
}
return -1;
}
function insertDivider(animate) {
var idx = findUnreadIndex();
if (idx < 0) {
status.textContent = '新着コメントはありません。';
return;
}
var items = list.querySelectorAll('.un__item');
var firstUnread = items[idx];
var divider = document.createElement('li');
divider.className = 'un__divider';
divider.setAttribute('role', 'listitem');
var dividerLabel = document.createElement('span');
dividerLabel.className = 'un__dividerLabel';
dividerLabel.textContent = 'ここから新着コメント';
divider.appendChild(dividerLabel);
list.insertBefore(divider, firstUnread);
for (var j = idx; j < items.length; j += 1) {
items[j].classList.add('is-new');
if (animate && !reduce) {
// 계산: 구분선 아래 새 댓글 행마다, 구분선 등장 이후 순서대로 옅은 하이라이트가
// 번져나갔다 가라앉아 "ここから新着コメント" 범위를 행 전체 면적으로도 보여준다.
items[j].animate(
[
{ backgroundColor: 'rgba(94,234,212,0.22)' },
{ backgroundColor: 'rgba(94,234,212,0)' }
],
{ duration: 520, delay: 260 + (j - idx) * NEW_ROW_HILITE_STAGGER_MS, easing: 'ease-out', fill: 'forwards' }
);
}
}
if (!animate || reduce) {
divider.style.opacity = '1';
divider.style.transform = 'translateY(0)';
} else {
divider.animate(
[
{ opacity: 0, transform: 'translateY(-14px)' },
{ opacity: 1, transform: 'translateY(3px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 460, delay: 220, easing: 'ease-out', fill: 'forwards' }
);
}
var newCount = items.length - idx;
status.textContent = '前回の訪問以降の新着コメント ' + newCount + '件です。';
}
if (reduce) {
insertDivider(false);
} else {
insertDivider(true);
}
})();import { useEffect, useRef } from "react";
import styles from "./CommentThreadUnreadDividerPosition.module.scss";
// 기법: 각 댓글의 "몇 분 전 작성" 값(가짜 상대값, Date 미사용)과 LAST_VISIT_MINUTES_AGO
// (마지막 방문이 몇 분 전인지)를 비교해, 방문 이후 쓰인 첫 댓글의 위치를 계산한다.
// minAgo < LAST_VISIT_MINUTES_AGO 인 첫 행 앞에 구분선을 삽입하고 위에서 살짝 내려와 멎는다.
const LAST_VISIT_MINUTES_AGO = 90;
const NEW_ROW_HILITE_STAGGER_MS = 90;
const ROWS = [
{ minAgo: 180, label: "읽은 댓글 1 — 이전 방문 때 이미 읽었습니다." },
{ minAgo: 120, label: "읽은 댓글 2 — 이것도 지난번에 봤어요." },
{ minAgo: 95, label: "읽은 댓글 3 — 여기까지가 지난 방문 범위." },
{ minAgo: 40, label: "새 댓글 1 — 마지막 방문 이후 달렸어요." },
{ minAgo: 12, label: "새 댓글 2 — 구분선 아래는 전부 새 댓글." },
];
export default function CommentThreadUnreadDividerPosition() {
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const list = listRef.current;
const status = statusRef.current;
if (!root || !list || !status) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
function insertDivider(animate: boolean) {
const items = Array.from(list!.querySelectorAll<HTMLElement>(".un__item"));
const idx = items.findIndex((el) => parseInt(el.getAttribute("data-min-ago")!, 10) < LAST_VISIT_MINUTES_AGO);
if (idx < 0) {
status!.textContent = "새 댓글이 없습니다.";
return;
}
const firstUnread = items[idx];
const divider = document.createElement("li");
divider.className = `un__divider ${styles.un__divider}`;
divider.setAttribute("role", "listitem");
const label = document.createElement("span");
label.className = styles.un__dividerLabel;
label.textContent = "여기부터 새 댓글";
divider.appendChild(label);
list!.insertBefore(divider, firstUnread);
for (let j = idx; j < items.length; j += 1) {
items[j].classList.add("is-new");
if (animate && !reduce) {
// 계산: 구분선 아래 새 댓글 행마다, 구분선 등장 이후 순서대로 옅은 하이라이트가
// 번져나갔다 가라앉아 "여기부터 새 댓글" 범위를 행 전체 면적으로도 보여준다.
items[j].animate(
[
{ backgroundColor: "rgba(94,234,212,0.22)" },
{ backgroundColor: "rgba(94,234,212,0)" },
],
{ duration: 520, delay: 260 + (j - idx) * NEW_ROW_HILITE_STAGGER_MS, easing: "ease-out", fill: "forwards" }
);
}
}
if (!animate || reduce) {
divider.style.opacity = "1";
divider.style.transform = "translateY(0)";
} else {
divider.animate(
[
{ opacity: 0, transform: "translateY(-14px)" },
{ opacity: 1, transform: "translateY(3px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 460, delay: 220, easing: "ease-out", fill: "forwards" }
);
}
const newCount = items.length - idx;
status!.textContent = `마지막 방문 이후 새 댓글 ${newCount}개입니다.`;
}
insertDivider(!reduce);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.un} is-demo`} ref={rootRef}>
<ul className={styles.un__list} role="list" ref={listRef}>
{ROWS.map((r, i) => (
<li key={i} className={`un__item ${styles.un__item}`} data-min-ago={r.minAgo}>{r.label}</li>
))}
</ul>
<p className={styles.un__sr} ref={statusRef} aria-live="polite" />
</div>
</main>
);
}08 最終訪問時刻と比較して位置を計算する未読区切り線 — 計測 累積面積 19.872% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · コメント欄 css 実装9パターン — 深さから削除後の詰めまで計算するリスト
このデモが属する記事
コメント欄 css 実装9パターン — 深さから削除後の詰めまで計算するリストコメント欄 css は、返信の深さ・行の高さ・経過時間を計算して動かすコメントリストの実装です。消えたコメントが占めていたピクセル値がそのまま下のコメントが上がる距離になり、返信のインデントは深さの段数から、固定コメントの光は固定されてから この記事のデモをすべて見る


