06가운데가 가장 큰 점 구분선과 한 번 지나가는 반짝임
점 크기를 종 모양 곡선으로 줄여 가운데 점이 가장 크게 하고, 그 위로 밝은 점이 한 번 지나간다.
쓰임점선 구분선에 높낮이 주기
DOT_COUNTDOT_MAX_PXGLINT_MS

<main class="stage">
<div class="dv is-demo" id="dvRoot">
<div class="dv__card">
<div class="dv__dots" id="dvDots" role="separator"></div>
</div>
<div class="dv__bar">
<button class="dv__btn" id="dvBtn" type="button">반짝임 다시</button>
<p class="dv__meta"><span class="dv__tag">DOT_COUNT</span> <b id="dvCount">0</b></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 점 지름은 가우스 곡선(가운데가 최대)으로, 반짝임은 점 줄을 한 번 훑는 가우스 띠로 계산한다.
@include stage($stage-ink);
.dv {
width: min(100%, 360px);
display: grid;
gap: $sp-2;
}
.dv__card {
box-sizing: border-box;
height: 96px;
display: grid;
place-items: center;
border-radius: $r-card;
background: $tick-panel;
}
.dv__dots {
display: flex;
align-items: center;
gap: $sp-2;
}
.dv__dot {
flex: none;
display: block;
border-radius: $r-pill;
background: $subject-lilac;
}
.dv__bar {
box-sizing: border-box;
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $sp-2 $sp-3;
}
.dv__btn {
box-sizing: border-box;
height: 40px;
padding: 0 $sp-5;
border: 0;
border-radius: $r-control;
background: $subject-lilac;
color: $subject-ink;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.dv__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.dv__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.dv__tag { font-weight: 700; letter-spacing: .08em; }
.dv__meta b { min-width: 2ch; text-align: right; font-variant-numeric: tabular-nums; color: $stage-paper; }
@media (prefers-reduced-motion: reduce) {
.dv__btn { transition: none; }
}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);
}
.dv {
width: min(100%, 360px);
display: grid;
gap: 8px;
}
.dv__card {
box-sizing: border-box;
height: 96px;
display: grid;
place-items: center;
border-radius: 16px;
background: #231f28;
}
.dv__dots {
display: flex;
align-items: center;
gap: 8px;
}
.dv__dot {
flex: none;
display: block;
border-radius: 999px;
background: #b9a0ff;
}
.dv__bar {
box-sizing: border-box;
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
}
.dv__btn {
box-sizing: border-box;
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.dv__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.dv__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.dv__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.dv__meta b {
min-width: 2ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
.dv__btn {
transition: none;
}
}(function () {
// 기법: i 번째 점 지름 = DOT_MAX_PX * exp(-(i - 중심)^2 / (2 * 시그마^2)) (가우스 곡선). 지름은 DOT_MIN_PX 밑으로 내려가지 않는다.
// 반짝임은 위치 g 가 점 줄을 한 번 훑는 값이고, 점마다 exp(-(i - g)^2 / (2 * GLINT_WIDTH^2)) 만큼 밝아지고 커진다.
var DOT_COUNT = 11;
var DOT_MAX_PX = 32;
var DOT_MIN_PX = 4;
var GLINT_WIDTH = 1.3;
var GLINT_MS = 1400;
var START_MS = 200;
var CYCLE_MS = 2000;
var BASE = [185, 160, 255];
var LIGHT = [255, 255, 255];
var root = document.getElementById('dvRoot');
var host = document.getElementById('dvDots');
var btn = document.getElementById('dvBtn');
var countEl = document.getElementById('dvCount');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var center = (DOT_COUNT - 1) / 2;
var sigma = DOT_COUNT / 5;
var dots = [];
var sizes = [];
var i;
for (i = 0; i < DOT_COUNT; i += 1) {
var d = document.createElement('span');
d.className = 'dv__dot';
host.appendChild(d);
dots.push(d);
var gauss = Math.exp(-Math.pow(i - center, 2) / (2 * sigma * sigma));
sizes.push(Math.max(DOT_MIN_PX, DOT_MAX_PX * gauss));
}
countEl.textContent = String(DOT_COUNT);
function paint(g) {
for (var k = 0; k < DOT_COUNT; k += 1) {
var b = g === null ? 0 : Math.exp(-Math.pow(k - g, 2) / (2 * GLINT_WIDTH * GLINT_WIDTH));
var px = sizes[k] * (1 + 0.3 * b);
var c = [0, 1, 2].map(function (n) { return Math.round(BASE[n] + (LIGHT[n] - BASE[n]) * b); });
var el = dots[k];
el.style.width = px.toFixed(1) + 'px';
el.style.height = px.toFixed(1) + 'px';
el.style.background = 'rgb(' + c.join(',') + ')';
}
}
function glintAt(local) {
var p = (local - START_MS) / GLINT_MS;
if (p < 0 || p > 1) return null;
return -2 + p * (DOT_COUNT + 3);
}
function stopDemo() { root.classList.remove('is-demo'); }
var restartAt = null;
btn.addEventListener('click', function () {
stopDemo();
restartAt = -1;
});
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
if (restartAt === -1) restartAt = ts;
if (root.classList.contains('is-demo')) paint(glintAt((ts - t0) % CYCLE_MS));
else if (restartAt !== null) paint(glintAt(ts - restartAt + START_MS));
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint(center);
} else {
paint(null);
requestAnimationFrame(tick);
}
})();// BellDots — vanilla 판(06-divl-dot-bell-curve-glint) 의 React 포트.
// 점 지름은 가우스 곡선(가운데가 최대)으로, 반짝임은 점 줄을 한 번 훑는 가우스 띠로 계산한다.
import { useEffect, useRef } from "react";
import styles from "./BellDots.module.scss";
type Box<T> = { current: T };
const DOT_COUNT = 11;
const DOT_MAX_PX = 32;
const DOT_MIN_PX = 4;
const GLINT_WIDTH = 1.3;
const GLINT_MS = 1400;
const START_MS = 200;
const CYCLE_MS = 2000;
const BASE: number[] = [185, 160, 255];
const LIGHT: number[] = [255, 255, 255];
const CENTER = (DOT_COUNT - 1) / 2;
const SIGMA = DOT_COUNT / 5;
const INDEXES: number[] = Array.from({ length: DOT_COUNT }, (_, i) => i);
const SIZES: number[] = INDEXES.map((i) =>
Math.max(DOT_MIN_PX, DOT_MAX_PX * Math.exp(-Math.pow(i - CENTER, 2) / (2 * SIGMA * SIGMA)))
);
function glintAt(local: number): number | null {
const p = (local - START_MS) / GLINT_MS;
if (p < 0 || p > 1) return null;
return -2 + p * (DOT_COUNT + 3);
}
export default function BellDots() {
const hostRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
useEffect(() => {
const host = hostRef.current;
const btn = btnRef.current;
if (!host || !btn) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const dots = Array.from(host.children) as HTMLElement[];
let demo = !reduce;
let restartAt: number | null = null;
let t0: number | null = null;
let raf = 0;
const paint = (g: number | null): void => {
for (let k = 0; k < DOT_COUNT; k += 1) {
const b = g === null ? 0 : Math.exp(-Math.pow(k - g, 2) / (2 * GLINT_WIDTH * GLINT_WIDTH));
const px = SIZES[k] * (1 + 0.3 * b);
const c = [0, 1, 2].map((n) => Math.round(BASE[n] + (LIGHT[n] - BASE[n]) * b));
const el = dots[k];
el.style.width = px.toFixed(1) + "px";
el.style.height = px.toFixed(1) + "px";
el.style.background = "rgb(" + c.join(",") + ")";
}
};
const onClick = (): void => {
demo = false;
restartAt = -1;
};
btn.addEventListener("click", onClick);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
if (restartAt === -1) restartAt = ts;
if (demo) paint(glintAt((ts - t0) % CYCLE_MS));
else if (restartAt !== null) paint(glintAt(ts - restartAt + START_MS));
raf = requestAnimationFrame(tick);
}
if (reduce) {
paint(CENTER);
} else {
paint(null);
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div className={styles.dv}>
<div className={styles.card}>
<div className={styles.dots} ref={hostRef} role="separator">
{INDEXES.map((i) => (
<span key={i} className={styles.dot} />
))}
</div>
</div>
<div className={styles.bar}>
<button className={styles.btn} ref={btnRef} type="button">반짝임 다시</button>
<p className={styles.meta}>
<span className={styles.tag}>DOT_COUNT</span> <b>{DOT_COUNT}</b>
</p>
</div>
</div>
);
}06 가운데가 가장 큰 점 구분선과 한 번 지나가는 반짝임 — 측정 누적면적 4.761% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 구분선 9가지 — 양 끝이 사라지고 글자 폭에 맞춰 선이 나뉘는 구분선 장면
이 데모가 속한 글
css 구분선 9가지 — 양 끝이 사라지고 글자 폭에 맞춰 선이 나뉘는 구분선 장면css 구분선은 선 한 줄이면 될 것 같지만, 양 끝이 뚝 잘려 어색하고 가운데 글자 폭에 선 길이가 안 맞고 섹션 경계가 직선이라 밋밋하다 — 01번·02번·04번이 하나씩 푼다. 이 편의 아홉 구분선은 선의 모양 이 글의 데모 전부 보기


