06현재 위치 크로스헤어 스캔
십자 조준선 아래로 링이 바깥으로 퍼지며 위치를 좁히고, 좁혀지면 그 자리에 현재 위치 점이 맥박 치기 시작합니다.
쓰임내 주변 검색의 시작점을 잡을 때
ring keyframesaria-livereduced-motion

<main class="stage">
<div class="lc is-demo">
<div class="lc__map">
<span class="lc__ring lc__ring--1" aria-hidden="true"></span>
<span class="lc__ring lc__ring--2" aria-hidden="true"></span>
<span class="lc__ring lc__ring--3" aria-hidden="true"></span>
<span class="lc__cross" aria-hidden="true"><svg viewBox="0 0 256 256" width="36" height="36"><g fill="currentColor"><path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2"/><path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg></span>
<span class="lc__dot" aria-hidden="true"></span>
</div>
<div class="lc__bar">
<!-- 진짜 <button> 이라 마우스 클릭과 키보드 Enter·Space 가 모두 click 으로 들어온다 -->
<button class="lc__btn" type="button" id="lc-btn">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/></g></svg>
<span>내 위치 찾기</span>
</button>
<span class="lc__read">
<span class="lc__say" id="lc-say" role="status" aria-live="polite">위치를 찾는 중</span>
<span class="lc__cut lc__cut--a" aria-hidden="true">위치를 찾는 중</span>
<span class="lc__cut lc__cut--b" aria-hidden="true">현재 위치 · 12m</span>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
// 가짜 지도 캔버스 — 9개 데모가 같은 색으로 한 가족을 이룬다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.lc {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// ── 지도 ─────────────────────────────────────────────
.lc__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 8px, transparent 8px 28px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 28px),
radial-gradient(circle at 8% 76%, $map-park 0 28px, transparent 28px),
linear-gradient(118deg, transparent 0 82%, $map-water 82% 94%, transparent 94%),
$map-land;
}
.lc__map::before,
.lc__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.lc__map::before { left: 24%; top: 14%; width: 13%; height: 30%; }
.lc__map::after { left: 68%; top: 56%; width: 15%; height: 30%; }
// 스캔 링 — 지름이 지도 높이와 같을 때가 scale(1)
.lc__ring {
position: absolute;
z-index: 1;
left: 50%; top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, .75);
border-radius: 50%;
background: rgba(47, 109, 246, .08);
opacity: 0;
transform: translate(-50%, -50%) scale(.18);
}
.lc__cross {
position: absolute;
z-index: 2;
left: 50%; top: 50%;
display: block;
color: rgba(23, 20, 26, .5);
transform: translate(-50%, -50%);
}
.lc__dot {
position: absolute;
z-index: 3;
left: 50%; top: 50%;
width: 12px; height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $color;
box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
// ── 컨트롤 ────────────────────────────────────────────
.lc__bar {
display: flex;
align-items: center;
gap: $sp-2;
}
.lc__btn {
flex: 0 0 auto;
box-sizing: border-box;
height: 28px;
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px; line-height: 14px; font-weight: 700;
cursor: pointer;
transition: transform $duration $ease-pop, box-shadow $duration $easing;
}
.lc__btn:hover { box-shadow: $shadow-press; }
.lc__btn:active { transform: scale(.96); }
.lc__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 상태 문구 — 사람이 누르면 .lc__say 가 실시간(aria-live), 자동 루프 때는 컷 2장이 번갈아 뜬다
.lc__read {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 16px;
overflow: hidden;
}
.lc__say,
.lc__cut {
position: absolute;
right: 0; top: 0;
font-size: 10px; line-height: 16px; font-weight: 700;
color: rgba(23, 20, 26, .66);
white-space: nowrap;
}
.lc__cut { opacity: 0; }
.lc.is-demo .lc__say { opacity: 0; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 링 3개가 시차를 두고 바깥으로 퍼지고(단축 금지 — 단축이 animation-delay 를 0 으로 지운다),
// 가운데 점은 맥박친다. 상태 문구는 60% 에 "찾는 중 → 현재 위치" 로 끊어 바뀐다.
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
animation-name: lc-scan;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 { animation-delay: -667ms; }
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 { animation-delay: -1334ms; }
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot {
animation-name: lc-pulse;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__cut {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lc.is-demo .lc__cut--a { animation-name: lc-say-a; }
.lc.is-demo .lc__cut--b { animation-name: lc-say-b; }
@keyframes lc-scan {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.18); }
14% { opacity: .9; transform: translate(-50%, -50%) scale(.4); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.04); }
}
@keyframes lc-pulse {
0% { transform: translate(-50%, -50%) scale(1); }
30% { transform: translate(-50%, -50%) scale(1.45); }
60% { transform: translate(-50%, -50%) scale(1); }
100% { transform: translate(-50%, -50%) scale(1); }
}
@keyframes lc-say-a { 0% { opacity: 1; } 60% { opacity: 0; } 90% { opacity: 1; } 100% { opacity: 1; } }
@keyframes lc-say-b { 0% { opacity: 0; } 60% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.lc { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring,
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot,
.lc.is-demo .lc__cut { animation: none !important; }
.lc.is-demo .lc__say { opacity: 1; } // 컷이 멈추면 실시간 문구가 대신 보인다
.lc__ring--1 { opacity: .9; transform: translate(-50%, -50%) scale(.55); }
.lc__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lc {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.lc__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 8px, transparent 8px 28px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 28px), radial-gradient(circle at 8% 76%, #c9e0bd 0 28px, transparent 28px), linear-gradient(118deg, transparent 0 82%, #bcd9ea 82% 94%, transparent 94%), #efe9dc;
}
.lc__map::before,
.lc__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.lc__map::before {
left: 24%;
top: 14%;
width: 13%;
height: 30%;
}
.lc__map::after {
left: 68%;
top: 56%;
width: 15%;
height: 30%;
}
.lc__ring {
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, 0.75);
border-radius: 50%;
background: rgba(47, 109, 246, 0.08);
opacity: 0;
transform: translate(-50%, -50%) scale(0.18);
}
.lc__cross {
position: absolute;
z-index: 2;
left: 50%;
top: 50%;
display: block;
color: rgba(23, 20, 26, 0.5);
transform: translate(-50%, -50%);
}
.lc__dot {
position: absolute;
z-index: 3;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.lc__bar {
display: flex;
align-items: center;
gap: 8px;
}
.lc__btn {
flex: 0 0 auto;
box-sizing: border-box;
height: 28px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
line-height: 14px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 220ms ease-out;
}
.lc__btn:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.lc__btn:active {
transform: scale(0.96);
}
.lc__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.lc__read {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 16px;
overflow: hidden;
}
.lc__say,
.lc__cut {
position: absolute;
right: 0;
top: 0;
font-size: 10px;
line-height: 16px;
font-weight: 700;
color: rgba(23, 20, 26, 0.66);
white-space: nowrap;
}
.lc__cut {
opacity: 0;
}
.lc.is-demo .lc__say {
opacity: 0;
}
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
animation-name: lc-scan;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 {
animation-delay: -667ms;
}
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 {
animation-delay: -1334ms;
}
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot {
animation-name: lc-pulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lc.is-demo .lc__cut--a {
animation-name: lc-say-a;
}
.lc.is-demo .lc__cut--b {
animation-name: lc-say-b;
}
@keyframes lc-scan {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.18);
}
14% {
opacity: 0.9;
transform: translate(-50%, -50%) scale(0.4);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.04);
}
}
@keyframes lc-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
}
30% {
transform: translate(-50%, -50%) scale(1.45);
}
60% {
transform: translate(-50%, -50%) scale(1);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes lc-say-a {
0% {
opacity: 1;
}
60% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lc-say-b {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.lc {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring,
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot,
.lc.is-demo .lc__cut {
animation: none !important;
}
.lc.is-demo .lc__say {
opacity: 1;
}
.lc__ring--1 {
opacity: 0.9;
transform: translate(-50%, -50%) scale(0.55);
}
.lc__btn {
transition: none;
}
}// 실제 동작: 버튼을 누르면(클릭·Enter·Space) 스캔 링이 다시 퍼지고, 1.4초 뒤 잠기며
// aria-live 영역의 문구가 바뀌어 스크린리더에도 알려진다.
var lcRoot = document.querySelector('.lc');
var lcBtn = document.getElementById('lc-btn');
var lcSay = document.getElementById('lc-say');
var lcTimer = 0;
function lcScan() {
lcRoot.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
lcRoot.classList.remove('is-locked');
lcRoot.classList.add('is-scanning');
lcSay.textContent = '위치를 찾는 중';
clearTimeout(lcTimer);
lcTimer = setTimeout(function () {
lcRoot.classList.remove('is-scanning');
lcRoot.classList.add('is-locked');
lcSay.textContent = '현재 위치 · 12m';
}, 1400);
}
lcBtn.addEventListener('click', lcScan);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LocateCrosshair.module.scss";
export type LocateCrosshairProps = {
buttonLabel?: string;
searchingLabel?: string;
lockedLabel?: string;
/** 스캔이 끝나고 위치가 잠기기까지 걸리는 시간(ms) */
scanMs?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LocateCrosshair({
buttonLabel = "Find my location",
searchingLabel = "Searching for your location",
lockedLabel = "Your location · 12m",
scanMs = 1400,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: LocateCrosshairProps) {
const [scanning, setScanning] = useState(true);
const [locked, setLocked] = useState(false);
const timer = useRef<number>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!scanning) return;
const id = window.setTimeout(() => { setScanning(false); setLocked(true); }, scanMs);
timer.current = id;
return () => window.clearTimeout(id);
}, [scanning, scanMs]);
function scan() {
if (timer.current !== null) window.clearTimeout(timer.current);
setLocked(false);
setScanning(true);
}
const stateClass = `${scanning ? styles.isScanning : ""} ${locked ? styles.isLocked : ""}`;
return (
<div className={`${styles.lc} ${stateClass}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.ring} ${styles.ring1}`} aria-hidden="true" />
<span className={`${styles.ring} ${styles.ring2}`} aria-hidden="true" />
<span className={`${styles.ring} ${styles.ring3}`} aria-hidden="true" />
<span className={styles.cross} aria-hidden="true">
<svg viewBox="0 0 256 256" width="36" height="36">
<g fill="currentColor">
<path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2" />
<path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24" />
</g>
</svg>
</span>
<span className={styles.dot} aria-hidden="true" />
</div>
<div className={styles.bar}>
{/* 진짜 <button> 이라 클릭과 키보드 Enter·Space 가 모두 들어온다 */}
<button type="button" className={styles.btn} onClick={scan}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2" />
<path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z" />
</g>
</svg>
<span>{buttonLabel}</span>
</button>
<span className={styles.read}>
<span className={styles.say} role="status" aria-live="polite">
{locked ? lockedLabel : searchingLabel}
</span>
</span>
</div>
</div>
);
}06 현재 위치 크로스헤어 스캔 — 측정 누적면적 7.419% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 지도 ui 디자인 9종 — 누르면 핀이 떨어지는 위치 선택 화면
이 데모가 속한 글
지도 ui 디자인 9종 — 누르면 핀이 떨어지는 위치 선택 화면지도 ui 디자인은 배달·예약 화면에서 "어디로 보낼까"를 지도 위에서 고르게 만드는 화면 설계입니다. 아홉 개 전부 지도 API 없이 CSS 격자로 그린 가짜 지도 위에서 돌아가고, 첫 번째는 누르면 핀이 떨어지는 이 글의 데모 전부 보기


