07コントラストスワップ — 背景の明るさに合わせてアイコン色を求め直す重ねボタン
ボタンが画像や動画の上に重なる時、下の背景の明るさ値を読み、明るければ暗いアイコン・暗ければ明るいアイコンに分けて、輪郭のコントラストを一定以上に保ち、変わる背景では色が自ら分かれます。
使いどころギャラリー画像上のスニペットコピー、動画サムネイル上のリンクボタン
relativeLuminancebackgroundColorclassList.toggle
<main class="stage">
<div class="backdrop" id="backdrop">
<button class="copyBtn" id="copyBtn" type="button" aria-label="画像の上でコピー">
<svg class="icon" id="icon" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
</button>
<p class="hint" id="hint">明るさ 0.00 · 暗い背景</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.backdrop {
position: relative;
box-sizing: border-box;
width: 320px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
border-radius: $r-card;
overflow: hidden;
background: $subject-ink;
transition: background-color $dur-base $ease-out;
}
.copyBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: $r-pill;
border: 2px solid rgba(255, 255, 255, .85);
background: rgba(23, 20, 26, .28);
cursor: pointer;
transition: color $dur-quick $ease-out, border-color $dur-quick $ease-out, background-color $dur-quick $ease-out;
}
.icon {
width: 22px;
height: 22px;
color: $stage-paper;
}
.backdrop.is-light .copyBtn {
border-color: rgba(23, 20, 26, .78);
background: rgba(255, 255, 255, .34);
}
.backdrop.is-light .icon {
color: $subject-ink;
}
.hint {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .46);
color: $stage-paper;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 600;
transition: background-color $dur-quick $ease-out, color $dur-quick $ease-out;
}
.backdrop.is-light .hint {
background: rgba(255, 255, 255, .58);
color: $subject-ink;
}
@media (max-width: 340px) {
.backdrop {
width: 260px;
height: 170px;
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop, .copyBtn, .hint {
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);
}
.backdrop {
position: relative;
box-sizing: border-box;
width: 320px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
transition: background-color 300ms ease-out;
}
.copyBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.85);
background: rgba(23, 20, 26, 0.28);
cursor: pointer;
transition: color 220ms ease-out, border-color 220ms ease-out, background-color 220ms ease-out;
}
.icon {
width: 22px;
height: 22px;
color: #ffffff;
}
.backdrop.is-light .copyBtn {
border-color: rgba(23, 20, 26, 0.78);
background: rgba(255, 255, 255, 0.34);
}
.backdrop.is-light .icon {
color: #17141a;
}
.hint {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.46);
color: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 600;
transition: background-color 220ms ease-out, color 220ms ease-out;
}
.backdrop.is-light .hint {
background: rgba(255, 255, 255, 0.58);
color: #17141a;
}
@media (max-width: 340px) {
.backdrop {
width: 260px;
height: 170px;
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop, .copyBtn, .hint {
transition: none;
}
}// 대비 아이콘 전환 — 버튼이 사진/영상 배경 위에 겹칠 때, 배경색의 상대 휘도를 계산해
// 밝으면 어두운 아이콘 · 어두우면 밝은 아이콘으로 가르고 테두리 대비를 유지한다.
// 계산 겹: 배경 색상 보간(장면 전환) → RGB → 상대휘도(WCAG 근사) → 임계 비교 → 아이콘/테두리 재계산.
// 자동 시연만, 실사용자 리스너 없음. 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
(function () {
var backdrop = document.getElementById('backdrop');
var icon = document.getElementById('icon');
var btn = document.getElementById('copyBtn');
var hint = document.getElementById('hint');
if (!backdrop || !icon || !btn || !hint) return;
var BRIGHTNESS_THRESHOLD = 0.55;
var CYCLE_MS = 2000;
// 장면 전환 — 사진/영상이 바뀌는 상황을 색 보간으로 흉내낸다 (연출용 색상, 임의 변경 금지)
var SCENES = [
[17, 20, 26],
[255, 210, 63],
[10, 79, 232],
[255, 247, 230]
];
function lerp(a, b, p) { return a + (b - a) * p; }
function sceneRgb(t) {
var span = CYCLE_MS / SCENES.length;
var idx = Math.floor(t / span) % SCENES.length;
var next = (idx + 1) % SCENES.length;
var p = (t % span) / span;
var a = SCENES[idx];
var b = SCENES[next];
return [
Math.round(lerp(a[0], b[0], p)),
Math.round(lerp(a[1], b[1], p)),
Math.round(lerp(a[2], b[2], p))
];
}
function channelLin(c) {
var v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function relativeLuminance(rgb) {
var r = channelLin(rgb[0]);
var g = channelLin(rgb[1]);
var b = channelLin(rgb[2]);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function apply(t) {
var rgb = sceneRgb(t);
var bg = 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
backdrop.style.backgroundColor = bg;
var brightness = relativeLuminance(rgb);
var isLight = brightness >= BRIGHTNESS_THRESHOLD;
backdrop.classList.toggle('is-light', isLight);
backdrop.classList.toggle('is-dark', !isLight);
hint.textContent = '明るさ ' + brightness.toFixed(2) + ' · ' + (isLight ? '明るい背景' : '暗い背景');
}
apply(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./CopyContrastIconSwap.module.scss";
// 대비 아이콘 전환 — 버튼이 사진/영상 배경 위에 겹칠 때, 배경색의 상대 휘도를 계산해
// 밝으면 어두운 아이콘 · 어두우면 밝은 아이콘으로 가르고 테두리 대비를 유지한다.
// 계산 겹: 배경 색상 보간(장면 전환) → RGB → 상대휘도(WCAG 근사) → 임계 비교 → 아이콘/테두리 재계산.
// 자동 시연만, 실사용자 리스너 없음. 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
const BRIGHTNESS_THRESHOLD = 0.55;
const CYCLE_MS = 2000;
const SCENES: [number, number, number][] = [
[17, 20, 26],
[255, 210, 63],
[10, 79, 232],
[255, 247, 230],
];
function lerp(a: number, b: number, p: number) {
return a + (b - a) * p;
}
function sceneRgb(t: number): [number, number, number] {
const span = CYCLE_MS / SCENES.length;
const idx = Math.floor(t / span) % SCENES.length;
const next = (idx + 1) % SCENES.length;
const p = (t % span) / span;
const a = SCENES[idx];
const b = SCENES[next];
return [
Math.round(lerp(a[0], b[0], p)),
Math.round(lerp(a[1], b[1], p)),
Math.round(lerp(a[2], b[2], p)),
];
}
function channelLin(c: number) {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function relativeLuminance(rgb: [number, number, number]) {
const r = channelLin(rgb[0]);
const g = channelLin(rgb[1]);
const b = channelLin(rgb[2]);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export default function CopyContrastIconSwap() {
const backdropRef = useRef<HTMLDivElement>(null);
const hintRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const backdrop = backdropRef.current;
const hint = hintRef.current;
if (!backdrop || !hint) return;
function apply(t: number) {
const rgb = sceneRgb(t);
backdrop!.style.backgroundColor = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
const brightness = relativeLuminance(rgb);
const isLight = brightness >= BRIGHTNESS_THRESHOLD;
backdrop!.classList.toggle(styles.isLight, isLight);
backdrop!.classList.toggle(styles.isDark, !isLight);
hint!.textContent = `밝기 ${brightness.toFixed(2)} · ${isLight ? "밝은 배경" : "어두운 배경"}`;
}
apply(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<div ref={backdropRef} className={styles.backdrop}>
<button className={styles.copyBtn} type="button" aria-label="이미지 위 복사">
<svg className={styles.icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
</button>
<p ref={hintRef} className={styles.hint}>밝기 0.00 · 어두운 배경</p>
</div>
</div>
);
}07 コントラストスワップ — 背景の明るさに合わせてアイコン色を求め直す重ねボタン — 計測 累積面積 42.094% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで
このデモが属する記事
コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで「コピー ボタン html」9選 — 9つのうち1つはコピー結果を2つのボタンが分けて持ち、1つはコピーした文字数だけ表示を埋め、もう1つは失敗したコピーの復帰時間を自ら計ります。 この記事のデモをすべて見る

