08권한 게이트 카드 — 권한 상태와 남은 대기 시간을 농도에 얹는 안내판
읽기 권한을 얻지 못한 상태가 되면 안내판이 대기 카드로 바뀌어, 권한 상태 값과 남은 대기 시간의 비율만큼 카드 농도와 안내 문구를 바꿉니다.
쓰임붙여넣기 칸이 있는 입력 화면, 보안 안내가 필요한 설정 페이지
setProperty--tintdataset.state

<main class="stage">
<section class="card" id="card">
<p class="title" id="title">붙여넣기 권한 확인 중</p>
<div class="track" id="track">
<div class="fill" id="fill"></div>
</div>
<p class="msg" id="msg">권한 상태를 확인하고 있어요</p>
<button class="retryBtn" id="retryBtn" type="button">권한 다시 요청</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.card {
--tint: 0.14;
box-sizing: border-box;
width: 300px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-3;
background: rgba($subject-orange-deep, var(--tint));
border: 1px solid rgba($subject-orange-deep, .3);
border-radius: $r-card;
padding: $sp-5;
transition: background-color $dur-quick $ease-out;
}
.title {
margin: 0;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 14px;
}
.track {
width: 100%;
height: 6px;
border-radius: $r-pill;
background: rgba($subject-ink, .12);
overflow: hidden;
}
.fill {
height: 100%;
width: 0%;
border-radius: $r-pill;
background: $subject-orange-deep;
transition: width $dur-instant linear;
}
.msg {
margin: 0;
color: rgba($subject-ink, .72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.4;
}
.retryBtn {
align-self: flex-start;
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: $subject-ink;
color: $stage-paper;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.card {
width: 260px;
padding: $sp-4;
}
}
@media (prefers-reduced-motion: reduce) {
.card, .fill {
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);
}
.card {
--tint: 0.14;
box-sizing: border-box;
width: 300px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
background: rgba(201, 48, 0, var(--tint));
border: 1px solid rgba(201, 48, 0, 0.3);
border-radius: 16px;
padding: 20px;
transition: background-color 220ms ease-out;
}
.title {
margin: 0;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 14px;
}
.track {
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: #c93000;
transition: width 140ms linear;
}
.msg {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.4;
}
.retryBtn {
align-self: flex-start;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #17141a;
color: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.card {
width: 260px;
padding: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.card, .fill {
transition: none;
}
}// 권한 대기 카드 — 읽기 권한을 얻지 못한 상태가 되면 안내판이 대기 카드로 바뀌고,
// 권한 상태 값(denied)과 남은 대기 시간의 비율만큼 카드 농도·안내 문구를 바꾼다.
// 계산 겹: 경과시간 → 남은 대기 비율(0~1) → 카드 배경 알파·진행바 폭 재계산.
// 실사용자 리스너 있음(권한 다시 요청 버튼). 실 클립보드 API/Permissions API 호출 없음 — 상태는 시뮬레이션.
(function () {
var card = document.getElementById('card');
var title = document.getElementById('title');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var msg = document.getElementById('msg');
var retryBtn = document.getElementById('retryBtn');
if (!card || !title || !track || !fill || !msg || !retryBtn) return;
var CHECK_MS = 500;
var WAIT_MS = 1600;
var CYCLE_MS = CHECK_MS + WAIT_MS;
function applyChecking() {
card.dataset.state = 'checking';
title.textContent = '붙여넣기 권한 확인 중';
msg.textContent = '권한 상태를 확인하고 있어요';
fill.style.width = '0%';
card.style.setProperty('--tint', '0.14');
}
function applyWaiting(elapsed) {
card.dataset.state = 'waiting';
var remain = Math.max(WAIT_MS - elapsed, 0);
var ratio = remain / WAIT_MS;
title.textContent = '권한이 아직 없어요';
msg.textContent = '거부됨 · 남은 대기 ' + (remain / 1000).toFixed(1) + 's';
fill.style.width = ((1 - ratio) * 100).toFixed(1) + '%';
card.style.setProperty('--tint', (0.14 + (1 - ratio) * 0.34).toFixed(3));
}
function setPhase(t) {
if (t < CHECK_MS) {
applyChecking();
} else {
applyWaiting(t - CHECK_MS);
}
}
setPhase(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var start = null;
var autoRaf = 0;
function autoLoop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
setPhase(t);
autoRaf = requestAnimationFrame(autoLoop);
}
if (!reduce) autoRaf = requestAnimationFrame(autoLoop);
retryBtn.addEventListener('click', function () {
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
var clickStart = null;
function userLoop(ts) {
if (clickStart === null) clickStart = ts;
var t = ts - clickStart;
if (t < CYCLE_MS) {
setPhase(t);
requestAnimationFrame(userLoop);
} else {
setPhase(0);
}
}
requestAnimationFrame(userLoop);
});
})();import { useEffect, useRef } from "react";
import styles from "./CopyPermissionGateCard.module.scss";
// 권한 대기 카드 — 읽기 권한을 얻지 못한 상태가 되면 안내판이 대기 카드로 바뀌고,
// 권한 상태 값(denied)과 남은 대기 시간의 비율만큼 카드 농도·안내 문구를 바꾼다.
// 계산 겹: 경과시간 → 남은 대기 비율(0~1) → 카드 배경 알파·진행바 폭 재계산.
// 실사용자 리스너 있음(권한 다시 요청 버튼). 실 클립보드 API/Permissions API 호출 없음 — 상태는 시뮬레이션.
const CHECK_MS = 500;
const WAIT_MS = 1600;
const CYCLE_MS = CHECK_MS + WAIT_MS;
export default function CopyPermissionGateCard() {
const cardRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLParagraphElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const msgRef = useRef<HTMLParagraphElement>(null);
const retryRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const card = cardRef.current;
const title = titleRef.current;
const fill = fillRef.current;
const msg = msgRef.current;
const retryBtn = retryRef.current;
if (!card || !title || !fill || !msg || !retryBtn) return;
function applyChecking() {
card!.dataset.state = "checking";
title!.textContent = "붙여넣기 권한 확인 중";
msg!.textContent = "권한 상태를 확인하고 있어요";
fill!.style.width = "0%";
card!.style.setProperty("--tint", "0.14");
}
function applyWaiting(elapsed: number) {
card!.dataset.state = "waiting";
const remain = Math.max(WAIT_MS - elapsed, 0);
const ratio = remain / WAIT_MS;
title!.textContent = "권한이 아직 없어요";
msg!.textContent = `거부됨 · 남은 대기 ${(remain / 1000).toFixed(1)}s`;
fill!.style.width = `${((1 - ratio) * 100).toFixed(1)}%`;
card!.style.setProperty("--tint", (0.14 + (1 - ratio) * 0.34).toFixed(3));
}
function setPhase(t: number) {
if (t < CHECK_MS) {
applyChecking();
} else {
applyWaiting(t - CHECK_MS);
}
}
setPhase(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let start: number | null = null;
let autoRaf = 0;
function autoLoop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
setPhase(t);
autoRaf = requestAnimationFrame(autoLoop);
}
if (!reduce) autoRaf = requestAnimationFrame(autoLoop);
function onRetry() {
if (autoRaf) {
cancelAnimationFrame(autoRaf);
autoRaf = 0;
}
let clickStart: number | null = null;
function userLoop(ts: number) {
if (clickStart === null) clickStart = ts;
const t = ts - clickStart;
if (t < CYCLE_MS) {
setPhase(t);
requestAnimationFrame(userLoop);
} else {
setPhase(0);
}
}
requestAnimationFrame(userLoop);
}
retryBtn.addEventListener("click", onRetry);
return () => {
if (autoRaf) cancelAnimationFrame(autoRaf);
retryBtn.removeEventListener("click", onRetry);
};
}, []);
return (
<div className={styles.stage}>
<section ref={cardRef} className={styles.card}>
<p ref={titleRef} className={styles.title}>붙여넣기 권한 확인 중</p>
<div className={styles.track}>
<div ref={fillRef} className={styles.fill} />
</div>
<p ref={msgRef} className={styles.msg}>권한 상태를 확인하고 있어요</p>
<button ref={retryRef} className={styles.retryBtn} type="button">권한 다시 요청</button>
</section>
</div>
);
}08 권한 게이트 카드 — 권한 상태와 남은 대기 시간을 농도에 얹는 안내판 — 측정 누적면적 29.676% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 클립보드 복사 버튼 UI 9종 — 코드·링크·텍스트 복사 피드백까지
이 데모가 속한 글
클립보드 복사 버튼 UI 9종 — 코드·링크·텍스트 복사 피드백까지아홉 개 중 하나는 복사 결과를 버튼 두 개가 나눠 갖고, 하나는 복사된 글자 수를 세어 자릿수만큼 표시를 채우고, 또 하나는 실패한 복사를 되돌리는 시간을 스스로 잽니다. 이 클립보드 복사 버튼 아홉 개는 실제 n 이 글의 데모 전부 보기


