03コーチマークのスポットライト
画面全体を覆うインク色の幕に穴が1つだけ空いて対象のボタンを照らし、その穴が左のボタンから右のカードへ移動するときに輪郭のリングが一度広がります。
使いどころ新機能の初回利用ガイド、管理画面の機能紹介
box-shadow 스포트라이트구멍 이동radius 12

<main class="stage is-demo">
<div class="screen">
<div class="app" aria-hidden="true">
<span class="btn"><span class="pi"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg></span>新規プロジェクト</span>
<span class="tile">レポート</span>
</div>
<div class="spot" aria-hidden="true"><i class="ring"></i></div>
<p class="tip">ここから始めます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 구멍이 버튼 → 카드로 갔다가 돌아오는 한 바퀴
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 안내를 받는 쪽 화면. 9999px 그림자를 이 상자 안으로 자른다
.screen {
position: relative;
box-sizing: border-box;
width: min(100%, 280px);
height: 148px;
border-radius: $r-card;
background: #fff;
overflow: hidden;
}
.app { display: flex; align-items: center; gap: $sp-3; padding: $sp-4; }
.btn {
box-sizing: border-box;
display: inline-flex; align-items: center; justify-content: center; gap: $sp-1;
width: 112px; height: 32px;
border-radius: $r-control;
background: $color;
color: #fff;
font-size: 11px; font-weight: 700;
}
.pi { display: block; width: 14px; height: 14px; }
.pi svg { width: 100%; height: 100%; display: block; }
.tile {
box-sizing: border-box;
display: inline-flex; align-items: center; justify-content: center;
width: 92px; height: 32px;
border-radius: $r-control;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 11px; font-weight: 600;
color: $subject-ink;
}
// 스포트라이트 — 구멍은 이 상자, 막은 바깥으로 퍼지는 거대한 box-shadow 하나
.spot {
position: absolute;
left: 12px; top: 12px;
width: 120px; height: 40px;
border-radius: $r-control;
box-shadow: 0 0 0 9999px rgba(23, 20, 26, .74);
transform-origin: left center; // 규칙8: 목표폭 scaleX 좌표 보간
transform: translateX(0) scaleX(1);
scale: 1; // 3차 탄성 개별 속성 — move 와 분리 합성. 꺼져도 1 이 기본
}
// 3차: spot 이 move 의 각 정차점(0~18% / 50~68%)에 앉는 순간 탄성 lerp 로
// 한 번 눌렸다 펴진다. move 는 transform 을, 이 층은 scale: 을 쓰므로 병렬 합성
.stage.is-demo .spot {
animation: move $duration $easing infinite,
spot-land $duration $ease-lerp-settle infinite;
}
.ring {
position: absolute; inset: 0;
border: 2px solid #fff;
border-radius: inherit;
opacity: 0;
}
.stage.is-demo .ring { animation: ping $duration $easing infinite; }
.tip {
position: absolute; left: 0; right: 0; bottom: $sp-4;
margin: 0;
text-align: center;
font-size: 12px; font-weight: 600;
color: #fff;
}
// 한 겹(풍선 감쇠 진자 + 더하기 아이콘 탄성) — 2026-09-15 형태 2차
// 풍선이 아래 꼭지를 축으로 살짝 흔들리며 주의를 끈다 (-2.5° → 1.8° → -1° → 0)
.stage.is-demo .tip {
animation: tip-pendulum $duration $ease-lerp-settle infinite,
tip-wobble $duration $ease-lerp-settle infinite;
transform-origin: bottom center;
rotate: 0deg;
}
@keyframes tip-pendulum {
0%, 10% { transform: rotate(0); }
20% { transform: rotate(-2.5deg); }
40% { transform: rotate(1.8deg); }
60% { transform: rotate(-1deg); }
80%,100% { transform: rotate(0); }
}
// 3차: 개별 속성(rotate:) 위상 어긋난 미세 진자 — 2차 진자와 곱이 아니라
// 더해져(회전은 합연산) 풍선이 흔들리는 시간이 길게 번진다
@keyframes tip-wobble {
0%, 4% { rotate: 0deg; }
16% { rotate: 1.4deg; }
34% { rotate: -1deg; }
52% { rotate: .55deg; }
70%, 100% { rotate: 0deg; }
}
// 스포트가 버튼에 앉는 구간(0~30%)에만 + 아이콘이 탄성 팝 —
// 카드 구간(50~68%)은 정지 상태로 쉰다
.stage.is-demo .pi {
animation-name: pi-spring;
animation-duration: $duration;
animation-timing-function: $ease-lerp-settle;
animation-iteration-count: infinite;
}
@keyframes pi-spring {
0%, 6% { transform: scale(1); }
10% { transform: scale(1.25); }
20% { transform: scale(.9); }
26% { transform: scale(1.05); }
30%, 100% { transform: scale(1); }
}
// 왼쪽 버튼(120px) → 오른쪽 카드(100px). 폭이 아니라 scaleX 로 줄인다
@keyframes move {
0%, 18% { transform: translateX(0) scaleX(1); }
50%, 68% { transform: translateX(124px) scaleX(.8333); }
100% { transform: translateX(0) scaleX(1); }
}
// 3차: move 가 정차하는 두 구간(0~18%·50~68%)의 시작점에서 개별 속성(scale:)
// 탄성이 한 번 튄다. move 는 transform 이라 scale: 과 곱합성돼 "쿵" 하고 앉는 느낌
@keyframes spot-land {
0% { scale: 1; }
4% { scale: 1.1; }
11% { scale: .96; }
18%, 46% { scale: 1; }
50% { scale: 1.1; }
57% { scale: .96; }
64%, 100% { scale: 1; }
}
// 구멍이 자리에 앉을 때마다 링이 한 번씩 퍼진다
@keyframes ping {
0%, 10% { opacity: .9; transform: scale(1); }
30% { opacity: 0; transform: scale(1.18); }
50%, 60% { opacity: .9; transform: scale(1); }
80%, 100% { opacity: 0; transform: scale(1.18); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .spot { animation: none; scale: 1; }
.stage.is-demo .ring { animation: none; opacity: .9; }
.stage.is-demo .tip { animation: none; rotate: 0deg; }
.stage.is-demo .pi { animation: 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);
}
.screen {
position: relative;
box-sizing: border-box;
width: min(100%, 280px);
height: 148px;
border-radius: 16px;
background: #fff;
overflow: hidden;
}
.app {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
.btn {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 112px;
height: 32px;
border-radius: 12px;
background: #2f6df6;
color: #fff;
font-size: 11px;
font-weight: 700;
}
.pi {
display: block;
width: 14px;
height: 14px;
}
.pi svg {
width: 100%;
height: 100%;
display: block;
}
.tile {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 92px;
height: 32px;
border-radius: 12px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
color: #17141a;
}
.spot {
position: absolute;
left: 12px;
top: 12px;
width: 120px;
height: 40px;
border-radius: 12px;
box-shadow: 0 0 0 9999px rgba(23, 20, 26, 0.74);
transform-origin: left center;
transform: translateX(0) scaleX(1);
scale: 1;
}
.stage.is-demo .spot {
animation: move 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, spot-land 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
}
.ring {
position: absolute;
inset: 0;
border: 2px solid #fff;
border-radius: inherit;
opacity: 0;
}
.stage.is-demo .ring {
animation: ping 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tip {
position: absolute;
left: 0;
right: 0;
bottom: 16px;
margin: 0;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #fff;
}
.stage.is-demo .tip {
animation: tip-pendulum 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite, tip-wobble 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
transform-origin: bottom center;
rotate: 0deg;
}
@keyframes tip-pendulum {
0%, 10% {
transform: rotate(0);
}
20% {
transform: rotate(-2.5deg);
}
40% {
transform: rotate(1.8deg);
}
60% {
transform: rotate(-1deg);
}
80%, 100% {
transform: rotate(0);
}
}
@keyframes tip-wobble {
0%, 4% {
rotate: 0deg;
}
16% {
rotate: 1.4deg;
}
34% {
rotate: -1deg;
}
52% {
rotate: 0.55deg;
}
70%, 100% {
rotate: 0deg;
}
}
.stage.is-demo .pi {
animation-name: pi-spring;
animation-duration: 2s;
animation-timing-function: linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1);
animation-iteration-count: infinite;
}
@keyframes pi-spring {
0%, 6% {
transform: scale(1);
}
10% {
transform: scale(1.25);
}
20% {
transform: scale(0.9);
}
26% {
transform: scale(1.05);
}
30%, 100% {
transform: scale(1);
}
}
@keyframes move {
0%, 18% {
transform: translateX(0) scaleX(1);
}
50%, 68% {
transform: translateX(124px) scaleX(0.8333);
}
100% {
transform: translateX(0) scaleX(1);
}
}
@keyframes spot-land {
0% {
scale: 1;
}
4% {
scale: 1.1;
}
11% {
scale: 0.96;
}
18%, 46% {
scale: 1;
}
50% {
scale: 1.1;
}
57% {
scale: 0.96;
}
64%, 100% {
scale: 1;
}
}
@keyframes ping {
0%, 10% {
opacity: 0.9;
transform: scale(1);
}
30% {
opacity: 0;
transform: scale(1.18);
}
50%, 60% {
opacity: 0.9;
transform: scale(1);
}
80%, 100% {
opacity: 0;
transform: scale(1.18);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .spot {
animation: none;
scale: 1;
}
.stage.is-demo .ring {
animation: none;
opacity: 0.9;
}
.stage.is-demo .tip {
animation: none;
rotate: 0deg;
}
.stage.is-demo .pi {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./CoachmarkSpotlight.module.scss";
export type CoachmarkSpotlightProps = {
duration?: number;
easing?: string;
color?: string;
buttonLabel?: string;
tileLabel?: string;
tip?: string;
};
export default function CoachmarkSpotlight({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
buttonLabel = "New project",
tileLabel = "Reports",
tip = "Start right here",
}: CoachmarkSpotlightProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.screen} style={vars}>
<div className={styles.app} aria-hidden="true">
<span className={styles.btn}>
<span className={styles.pi}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
<path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2" />
<path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z" />
</svg>
</span>
{buttonLabel}
</span>
<span className={styles.tile}>{tileLabel}</span>
</div>
{/* 구멍 하나 + 바깥으로 퍼지는 거대한 box-shadow 가 막 전체를 만든다 */}
<div className={styles.spot} aria-hidden="true"><i className={styles.ring} /></div>
<p className={styles.tip}>{tip}</p>
</div>
);
}03 コーチマークのスポットライト — 計測 累積面積 8.648% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · チュートリアル UI デザイン9選 — コピペOKのツアー部品
このデモが属する記事
チュートリアル UI デザイン9選 — コピペOKのツアー部品チュートリアル ui デザインは、登録したばかりの人に「次にどこを押せばいいか」を画面上で直接指し示す部品です。チェックリスト・スポットライト・ステップ式ツールチップなど、形はだいたい決まっているので、選んで貼るだけで済みます。実際に動かし この記事のデモをすべて見る

