08Vòng con trỏ theo tiến độ cuộn
Khi cuộn bên trong, vòng nhỏ theo con trỏ sẽ đầy dần theo tiến độ, đến cuối thì chuyển thành dấu tick.
Dùng choTiến độ đọc bài dài, xác nhận cuộn hết điều khoản
stroke-dashoffsetscrollTop/scrollHeight 비율pointermove translate

<main class="stage">
<div class="pr-box is-demo">
<div class="pr-content">
<p>Cuộn bên trong khung này và</p>
<p>vòng cạnh con trỏ</p>
<p>đầy dần theo tiến độ</p>
<p>và chuyển thành dấu tick khi hết</p>
</div>
<div class="pr-ring" aria-hidden="true">
<svg viewBox="0 0 36 36">
<circle class="pr-ring__track" cx="18" cy="18" r="15"/>
<circle class="pr-ring__fill" cx="18" cy="18" r="15"/>
</svg>
<svg class="pr-ring__check" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.pr-box {
position: relative;
width: min(230px, 76vw);
height: 150px;
overflow-y: auto;
border-radius: $r-card;
background: #1c1822;
cursor: none;
}
.pr-content {
padding: $sp-4;
height: 340px;
p { margin: 0 0 $sp-6; font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, .55); }
}
.pr-ring {
position: absolute;
left: 0;
top: 0;
width: 34px;
height: 34px;
margin: -17px 0 0 -17px;
pointer-events: none;
transform: translate(24px, 112px);
transition: transform $duration $easing;
}
.pr-ring svg:first-child { transform: rotate(-90deg); }
.pr-ring__track { fill: none; stroke: rgba(255, 255, 255, .18); stroke-width: 3; }
.pr-ring__fill {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 94.2;
stroke-dashoffset: var(--dash, 94.2);
}
.pr-ring__check {
position: absolute;
inset: 8px;
color: $subject-mint;
opacity: 0;
}
.pr-box.is-done .pr-ring__check { opacity: 1; }
.pr-box.is-done .pr-ring svg:first-child { opacity: 0; }
// 데모 강제 루프 — 실제 스크롤 없이도 링이 채워졌다가 체크로 바뀐다
.pr-box.is-demo { overflow: hidden; }
.pr-box.is-demo .pr-ring { animation: pr-move $dur-loop $easing infinite; transition: none; }
.pr-box.is-demo .pr-ring__fill { animation: pr-fill $dur-loop $easing infinite; }
.pr-box.is-demo .pr-ring__check { animation: pr-check $dur-loop $easing infinite; }
@keyframes pr-move {
0%, 8% { transform: translate(24px, 112px); }
50% { transform: translate(150px, 40px); }
92%, 100% { transform: translate(24px, 112px); }
}
@keyframes pr-fill {
0%, 8% { stroke-dashoffset: 94.2; }
50%, 62% { stroke-dashoffset: 0; }
92%, 100% { stroke-dashoffset: 94.2; }
}
@keyframes pr-check {
0%, 46% { opacity: 0; }
52%, 60% { opacity: 1; }
68%, 100% { opacity: 0; }
}
@media (hover: none), (pointer: coarse) {
.pr-box { cursor: auto; } // 터치엔 커서가 없다 — 링은 스크롤 위치에만 반응하는 진행 표시로 남는다
}
@media (prefers-reduced-motion: reduce) {
.pr-ring, .pr-ring__fill, .pr-ring__check { animation: none !important; 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);
}
.pr-box {
position: relative;
width: min(230px, 76vw);
height: 150px;
overflow-y: auto;
border-radius: 16px;
background: #1c1822;
cursor: none;
}
.pr-content {
padding: 16px;
height: 340px;
}
.pr-content p {
margin: 0 0 24px;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.55);
}
.pr-ring {
position: absolute;
left: 0;
top: 0;
width: 34px;
height: 34px;
margin: -17px 0 0 -17px;
pointer-events: none;
transform: translate(24px, 112px);
transition: transform 220ms ease-out;
}
.pr-ring svg:first-child {
transform: rotate(-90deg);
}
.pr-ring__track {
fill: none;
stroke: rgba(255, 255, 255, 0.18);
stroke-width: 3;
}
.pr-ring__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 94.2;
stroke-dashoffset: var(--dash, 94.2);
}
.pr-ring__check {
position: absolute;
inset: 8px;
color: #4cd4a6;
opacity: 0;
}
.pr-box.is-done .pr-ring__check {
opacity: 1;
}
.pr-box.is-done .pr-ring svg:first-child {
opacity: 0;
}
.pr-box.is-demo {
overflow: hidden;
}
.pr-box.is-demo .pr-ring {
animation: pr-move 2s ease-out infinite;
transition: none;
}
.pr-box.is-demo .pr-ring__fill {
animation: pr-fill 2s ease-out infinite;
}
.pr-box.is-demo .pr-ring__check {
animation: pr-check 2s ease-out infinite;
}
@keyframes pr-move {
0%, 8% {
transform: translate(24px, 112px);
}
50% {
transform: translate(150px, 40px);
}
92%, 100% {
transform: translate(24px, 112px);
}
}
@keyframes pr-fill {
0%, 8% {
stroke-dashoffset: 94.2;
}
50%, 62% {
stroke-dashoffset: 0;
}
92%, 100% {
stroke-dashoffset: 94.2;
}
}
@keyframes pr-check {
0%, 46% {
opacity: 0;
}
52%, 60% {
opacity: 1;
}
68%, 100% {
opacity: 0;
}
}
@media (hover: none), (pointer: coarse) {
.pr-box {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.pr-ring, .pr-ring__fill, .pr-ring__check {
animation: none !important;
transition: none !important;
}
}(function () {
var box = document.querySelector('.pr-box');
var ring = box.querySelector('.pr-ring');
var CIRC = 2 * Math.PI * 15;
// 실제 사용자: 포인터를 따라가고, 스크롤 진행률만큼 링 테두리가 채워진다
box.addEventListener('pointermove', function (e) {
if (box.classList.contains('is-demo')) return;
var r = box.getBoundingClientRect();
ring.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
box.addEventListener('scroll', function () {
if (box.classList.contains('is-demo')) return;
var max = box.scrollHeight - box.clientHeight;
var pct = max > 0 ? box.scrollTop / max : 0;
ring.style.setProperty('--dash', (CIRC * (1 - pct)).toFixed(1));
box.classList.toggle('is-done', pct > 0.98);
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./CursorScrollProgressRing.module.scss";
export type CursorScrollProgressRingProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CIRC = 2 * Math.PI * 15;
/** pointermove 로 커서를 따라가고, 내부 스크롤 비율만큼 stroke-dashoffset 을 채운다 */
export default function CursorScrollProgressRing({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: CursorScrollProgressRingProps) {
const ringRef = useRef<HTMLDivElement>(null);
const [done, setDone] = useState(false);
const [dash, setDash] = useState(CIRC);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const move = (e: PointerEvent<HTMLDivElement>) => {
const ring = ringRef.current;
if (!ring) return;
const r = e.currentTarget.getBoundingClientRect();
ring.style.transform = `translate(${e.clientX - r.left}px,${e.clientY - r.top}px)`;
};
const scroll = (e: { currentTarget: HTMLDivElement }) => {
const box = e.currentTarget;
const max = box.scrollHeight - box.clientHeight;
const pct = max > 0 ? box.scrollTop / max : 0;
setDash(CIRC * (1 - pct));
setDone(pct > 0.98);
};
return (
<div className={`${styles.box} ${done ? styles.isDone : ""}`} style={vars} onPointerMove={move} onScroll={scroll}>
<div className={styles.content}>
<p>이 안에서 스크롤하면</p>
<p>커서 옆 링이</p>
<p>진행률만큼 채워진다</p>
<p>끝까지 가면 체크로 바뀐다</p>
</div>
<div ref={ringRef} className={styles.ring} aria-hidden="true">
<svg viewBox="0 0 36 36">
<circle className={styles.track} cx="18" cy="18" r="15" />
<circle className={styles.fill} cx="18" cy="18" r="15" style={{ strokeDashoffset: dash }} />
</svg>
<svg className={styles.check} viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" /><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" /></g></svg>
</div>
</div>
);
}08 Vòng con trỏ theo tiến độ cuộn — Diện tích đo được 3.108% · khung hình chuyển động 22/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 hiệu ứng chuột máy tính bạn có thể copy-paste
Bài viết chứa demo này
9 hiệu ứng chuột máy tính bạn có thể copy-pasteHiệu ứng chuột máy tính là tương tác nhỏ phản ứng theo cách con trỏ di chuyển, click, kéo hoặc cuộn. Chỉ mới di chuyển c Xem tất cả demo trong bài này


