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 オリジナル · SCSS と React は zip に同梱 · css 区切り線 9パターン — 両端が消えて文字の幅で線が分かれる区切りの場面
このデモが属する記事
css 区切り線 9パターン — 両端が消えて文字の幅で線が分かれる区切りの場面css 区切り線は線一本で済みそうだが、両端がぷつりと切れ、中央の文字の幅に線の長さが合わず、セクションの境目が直線で単調になる — 01番・02番・04番が一つずつ直す。このページの九つは、線の形を値で計算して描きます。 この記事のデモをすべて見る


