07Lưới chọn màu swatch
Sáu mẫu màu xếp lưới 3×2; chọn một mẫu thì một badge tick nhỏ ở góc phóng từ scale(0) lên 1.
Dùng choLưới tùy chọn màu sản phẩm, màn hình chọn màu theme
scale(0)→scale(1) 체크3×2 grid0.22초

<main class="stage">
<fieldset class="cw is-demo">
<legend class="sr-only">Chọn màu</legend>
<label class="cw__cell"><input type="radio" name="cw1" class="cw__input" checked aria-label="Màu 1"><span class="cw__swatch cw__swatch--1"></span><span class="cw__check" aria-hidden="true"><svg class="cw__check-icon" 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></span></label>
<label class="cw__cell"><input type="radio" name="cw1" class="cw__input" aria-label="Màu 2"><span class="cw__swatch cw__swatch--2"></span><span class="cw__check" aria-hidden="true"><svg class="cw__check-icon" 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></span></label>
<label class="cw__cell"><input type="radio" name="cw1" class="cw__input" aria-label="Màu 3"><span class="cw__swatch cw__swatch--3"></span><span class="cw__check" aria-hidden="true"><svg class="cw__check-icon" 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></span></label>
<label class="cw__cell"><input type="radio" name="cw1" class="cw__input" aria-label="Màu 4"><span class="cw__swatch cw__swatch--4"></span><span class="cw__check" aria-hidden="true"><svg class="cw__check-icon" 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></span></label>
<label class="cw__cell"><input type="radio" name="cw1" class="cw__input" aria-label="Màu 5"><span class="cw__swatch cw__swatch--5"></span><span class="cw__check" aria-hidden="true"><svg class="cw__check-icon" 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></span></label>
<label class="cw__cell"><input type="radio" name="cw1" class="cw__input" aria-label="Màu 6"><span class="cw__swatch cw__swatch--6"></span><span class="cw__check" aria-hidden="true"><svg class="cw__check-icon" 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></span></label>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.cw {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: $sp-3; border: 0; margin: 0; padding: 0;
}
.cw__input { position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; }
.cw__cell { position: relative; display: inline-flex; cursor: pointer; }
.cw__swatch {
width: 34px; height: 34px; border-radius: $r-sm;
box-shadow: $shadow-press;
display: block;
}
.cw__swatch--1 { background: $subject-blue; }
.cw__swatch--2 { background: $stage-yellow; }
.cw__swatch--3 { background: $stage-ink; }
.cw__swatch--4 { background: $error; }
.cw__swatch--5 { background: $subject-cream; }
.cw__swatch--6 { background: $stage-orange; }
.cw__check {
position: absolute; right: -4px; bottom: -4px;
width: 16px; height: 16px;
border-radius: 50%;
background: $subject-ink;
transform: scale(0);
transition: transform $duration $easing;
}
.cw__check-icon {
position: absolute; inset: 3px;
color: $color; // Phosphor duotone: g fill="currentColor" 가 이 값을 물려받는다
}
.cw__cell:has(.cw__input:checked) .cw__check {
transform: scale(1);
animation: cw-check-jelly $duration $easing;
}
// 체크 등장 젤리 오버슛: scale 0→1.25→0.9→1 + 회전 살짝 -15deg→0
@keyframes cw-check-jelly {
0% { transform: scale(0) rotate(-15deg); }
55% { transform: scale(1.25) rotate(4deg); }
80% { transform: scale(.9) rotate(-2deg); }
100% { transform: scale(1) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.cw__check { transition: none; }
.cw__cell:has(.cw__input:checked) .cw__check { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.cw {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
border: 0;
margin: 0;
padding: 0;
}
.cw__input {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px;
height: 1px;
}
.cw__cell {
position: relative;
display: inline-flex;
cursor: pointer;
}
.cw__swatch {
width: 34px;
height: 34px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
display: block;
}
.cw__swatch--1 {
background: #2f6df6;
}
.cw__swatch--2 {
background: #ffd23f;
}
.cw__swatch--3 {
background: #17141a;
}
.cw__swatch--4 {
background: #c2361a;
}
.cw__swatch--5 {
background: #fff7e6;
}
.cw__swatch--6 {
background: #ff4d1f;
}
.cw__check {
position: absolute;
right: -4px;
bottom: -4px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #17141a;
transform: scale(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cw__check-icon {
position: absolute;
inset: 3px;
color: #fff7e6;
}
.cw__cell:has(.cw__input:checked) .cw__check {
transform: scale(1);
animation: cw-check-jelly 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes cw-check-jelly {
0% {
transform: scale(0) rotate(-15deg);
}
55% {
transform: scale(1.25) rotate(4deg);
}
80% {
transform: scale(0.9) rotate(-2deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.cw__check {
transition: none;
}
.cw__cell:has(.cw__input:checked) .cw__check {
animation: none !important;
}
}// .is-demo 자동 순환 (실제 클릭이 오면 멈춘다, 8줄)
// 1번은 이미 checked 라 첫 발화가 같은 항목을 다시 찍는 무동작이었다 — i=1 로 시작 + 즉시 1회 실행해
// 로드 직후(렌더 캡처 구간)에도 실제 스프링 오버슛이 한 번은 반드시 보이게 한다
var cw = document.querySelector('.cw');
var inputs = cw.querySelectorAll('.cw__input'); var i = 1;
function cwCycle() { inputs[i % inputs.length].checked = true; i++; }
cwCycle();
var t = setInterval(cwCycle, 600);
inputs.forEach(function (el) { el.addEventListener('pointerdown', function () { cw.classList.remove('is-demo'); clearInterval(t); }); });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColorSwatchGridCheckSelect.module.scss";
export type Swatch = { value: string; color: string; label: string };
export type ColorSwatchGridCheckSelectProps = {
legend?: string;
swatches?: Swatch[];
duration?: number;
easing?: string;
checkColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--check"?: string };
export default function ColorSwatchGridCheckSelect({
legend = "Color selection",
swatches = [
{ value: "1", color: "#2f6df6", label: "Color 1" },
{ value: "2", color: "#ffd23f", label: "Color 2" },
{ value: "3", color: "#17141a", label: "Color 3" },
{ value: "4", color: "#c2361a", label: "Color 4" },
{ value: "5", color: "#fff7e6", label: "Color 5" },
{ value: "6", color: "#ff4d1f", label: "Color 6" },
],
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
checkColor = "#fff7e6",
}: ColorSwatchGridCheckSelectProps) {
const [value, setValue] = useState(swatches[0]?.value);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--check": checkColor };
return (
<fieldset className={styles.cw} style={vars}>
<legend className={styles.srOnly}>{legend}</legend>
{swatches.map((s) => (
<label key={s.value} className={styles.cell}>
<input
type="radio"
name="swatch"
className={styles.srInput}
checked={value === s.value}
onChange={() => setValue(s.value)}
aria-label={s.label}
/>
<span className={styles.swatch} style={{ background: s.color }} />
<span className={styles.check} data-checked={value === s.value} aria-hidden="true">
<svg className={styles.checkIcon} 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>
</span>
</label>
))}
</fieldset>
);
}07 Lưới chọn màu swatch — Diện tích đo được 0.578% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 Segmented Control UI — dán là chạy
Bài viết chứa demo này
9 Segmented Control UI — dán là chạySegmented control là nút chuyển tab hình viên thuốc mà bạn hay thấy trong màn hình cài đặt iOS, chọn một trong vài lựa c Xem tất cả demo trong bài này


