05Recent colors: eight slots that shift forward
Each time you commit a color a slot slides in at the front, the ninth drops off the end, and the list survives in localStorage.
Best forReusing colors across edits, recent swatches in drawing apps
localStorageanimation-delayaria-pressed

<main class="stage">
<div class="rc is-demo">
<div class="rc__top">
<button class="rc__now" id="nowSwatch" type="button" aria-label="Current color"></button>
<button class="rc__add" id="addButton" type="button">Add</button>
</div>
<p class="rc__cap" aria-hidden="true">Recent colors</p>
<div class="rc__hist">
<button class="rc__cell" type="button" aria-pressed="true" aria-label="Recent color"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="Recent color"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="Recent color"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="Recent color"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="Recent color"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="Recent color"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="Recent color"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="Recent color"></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 최근 고른 색 여덟 칸 — 색을 담을 때마다 맨 앞에 한 칸이 끼어들고 아홉 번째는 잘려 나간다.
// 칸 색은 등록 속성 --c 하나로만 들어간다. 사람이 쓸 때는 JS 가 인라인으로 넣고,
// 격자 미리보기에서는 여덟 칸이 **같은 키프레임**을 animation-delay 만 0.25초씩 달리해서 돈다.
// 여덟 색이 0.25초마다 한 칸씩 옆으로 옮겨 가는 것이 곧 "앞에 하나 끼어들고 뒤가 밀린다" 이고,
// 2초 뒤 정확히 제자리로 돌아오므로 텔레포트가 없다. 색 교체는 steps(1, end) 라 반투명 유령 프레임이 없다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
@property --c { syntax: "<color>"; inherits: false; initial-value: #{$subject-blue}; }
.rc {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
}
.rc * { box-sizing: border-box; }
.rc__top {
display: flex;
align-items: center;
gap: $sp-3;
}
.rc__now {
width: 48px;
height: 48px;
padding: 0;
border: 2px solid rgba(255, 247, 230, .28);
border-radius: $r-sm;
background: var(--c);
cursor: pointer;
}
.rc__now:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.rc__add {
height: 32px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.rc__add:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.rc__cap {
margin: $sp-3 0 $sp-1;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(255, 247, 230, .66);
}
.rc__hist {
display: grid;
grid-template-columns: repeat(8, 26px);
gap: $sp-2;
}
.rc__cell {
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: $r-xs;
background: var(--c);
cursor: pointer;
}
// 고른 칸은 테두리 고리로 표시한다(색만으로 알리지 않도록 라벨에 HEX 값도 함께 읽어 준다)
.rc__cell[aria-pressed="true"] { box-shadow: 0 0 0 2px $subject-cream; }
.rc__cell:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
animation-name: histRoll;
animation-duration: $duration;
animation-timing-function: steps(1, end); // 색 교체는 한 프레임에 컷 — 크로스페이드는 유령을 남긴다
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 칸마다 0.25초씩 위상을 늦춘다. 음수 지연이라 첫 장부터 제 색으로 시작한다
// (-2s 는 한 주기 전이므로 0s 와 같은 위상이다. 지연을 뒤로 미루려면 주기만큼 당겨 써야 한다)
.rc.is-demo .rc__cell:nth-child(1) { animation-delay: -2s; }
.rc.is-demo .rc__cell:nth-child(2) { animation-delay: -1.75s; }
.rc.is-demo .rc__cell:nth-child(3) { animation-delay: -1.5s; }
.rc.is-demo .rc__cell:nth-child(4) { animation-delay: -1.25s; }
.rc.is-demo .rc__cell:nth-child(5) { animation-delay: -1s; }
.rc.is-demo .rc__cell:nth-child(6) { animation-delay: -0.75s; }
.rc.is-demo .rc__cell:nth-child(7) { animation-delay: -0.5s; }
.rc.is-demo .rc__cell:nth-child(8) { animation-delay: -0.25s; }
// 맨 앞 칸과 「지금 색」 사각형은 같은 위상이라 늘 같은 색이다.
// 컷만으로는 움직인 프레임이 모자라니 컷 직후 튀어오르는 보조 애니메이션을 겹친다 —
// transform 이라 중간 프레임이 반투명 유령이 되지 않는다. 커지는 건 자기 중심에서
.rc.is-demo .rc__cell:nth-child(1),
.rc.is-demo .rc__now {
animation-name: histRoll, histPop;
animation-duration: $duration, calc($duration / 8);
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-delay: -2s, 0s;
animation-fill-mode: backwards, backwards;
}
@keyframes histRoll {
0% { --c: #{$subject-blue}; }
12.5% { --c: #{$subject-sky}; }
25% { --c: #{$subject-mint}; }
37.5% { --c: #{$stage-yellow}; }
50% { --c: #{$stage-orange}; }
62.5% { --c: #{$subject-pink}; }
75% { --c: #{$subject-lilac}; }
87.5% { --c: #{$subject-cream}; }
100% { --c: #{$subject-blue}; }
}
@keyframes histPop {
0% { transform: scale(.86); }
60%, 100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --c {
syntax: "<color>";
inherits: false;
initial-value: #2f6df6;
}
.rc {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
}
.rc * {
box-sizing: border-box;
}
.rc__top {
display: flex;
align-items: center;
gap: 12px;
}
.rc__now {
width: 48px;
height: 48px;
padding: 0;
border: 2px solid rgba(255, 247, 230, 0.28);
border-radius: 8px;
background: var(--c);
cursor: pointer;
}
.rc__now:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.rc__add {
height: 32px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.rc__add:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.rc__cap {
margin: 12px 0 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(255, 247, 230, 0.66);
}
.rc__hist {
display: grid;
grid-template-columns: repeat(8, 26px);
gap: 8px;
}
.rc__cell {
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 4px;
background: var(--c);
cursor: pointer;
}
.rc__cell[aria-pressed=true] {
box-shadow: 0 0 0 2px #fff7e6;
}
.rc__cell:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
animation-name: histRoll;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .rc__cell:nth-child(1) {
animation-delay: -2s;
}
.rc.is-demo .rc__cell:nth-child(2) {
animation-delay: -1.75s;
}
.rc.is-demo .rc__cell:nth-child(3) {
animation-delay: -1.5s;
}
.rc.is-demo .rc__cell:nth-child(4) {
animation-delay: -1.25s;
}
.rc.is-demo .rc__cell:nth-child(5) {
animation-delay: -1s;
}
.rc.is-demo .rc__cell:nth-child(6) {
animation-delay: -0.75s;
}
.rc.is-demo .rc__cell:nth-child(7) {
animation-delay: -0.5s;
}
.rc.is-demo .rc__cell:nth-child(8) {
animation-delay: -0.25s;
}
.rc.is-demo .rc__cell:nth-child(1),
.rc.is-demo .rc__now {
animation-name: histRoll, histPop;
animation-duration: 2s, 0.25s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
animation-delay: -2s, 0s;
animation-fill-mode: backwards, backwards;
}
@keyframes histRoll {
0% {
--c: #2f6df6;
}
12.5% {
--c: #6ec8ff;
}
25% {
--c: #4cd4a6;
}
37.5% {
--c: #ffd23f;
}
50% {
--c: #ff4d1f;
}
62.5% {
--c: #ff9ebb;
}
75% {
--c: #b9a0ff;
}
87.5% {
--c: #fff7e6;
}
100% {
--c: #2f6df6;
}
}
@keyframes histPop {
0% {
transform: scale(0.86);
}
60%, 100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
animation: none !important;
}
}(function () {
var root = document.querySelector('.rc');
var now = document.getElementById('nowSwatch');
var addButton = document.getElementById('addButton');
var cells = [].slice.call(document.querySelectorAll('.rc__cell'));
var nowFormat = 'Current color %x, press for the next one';
var cellFormat = 'Recent color %x';
var palette = ['#2F6DF6', '#6EC8FF', '#4CD4A6', '#FFD23F', '#FF4D1F', '#FF9EBB', '#B9A0FF', '#FFF7E6'];
var storeKey = 'godrich-recent-colors';
var step = 0;
var recent = read();
// 목록은 localStorage 에 남는다. 파일로 열면 저장소가 막힐 수 있어 실패해도 기본 팔레트로 이어간다
function read() {
try {
var raw = window.localStorage.getItem(storeKey);
var list = raw ? JSON.parse(raw) : null;
if (list && list.length) { return list.slice(0, 8); }
} catch (err) { /* 저장소를 못 쓰는 환경 */ }
return palette.slice();
}
function save(list) {
try { window.localStorage.setItem(storeKey, JSON.stringify(list)); } catch (err) { /* 무시 */ }
}
// 글자도 색도 JS 가 한 곳에서만 쓴다 — 칸 색은 --c, 라벨은 같은 HEX 값에서 나온다
function paint() {
now.style.setProperty('--c', palette[step]);
now.setAttribute('aria-label', nowFormat.replace('%x', palette[step]));
cells.forEach(function (cell, i) {
var hex = recent[i] || palette[i];
cell.style.setProperty('--c', hex);
cell.setAttribute('aria-label', cellFormat.replace('%x', hex));
cell.setAttribute('aria-pressed', hex === palette[step] ? 'true' : 'false');
});
}
function stopLoop() { root.classList.remove('is-demo'); }
now.addEventListener('click', function () {
stopLoop();
step = (step + 1) % palette.length;
paint();
});
addButton.addEventListener('click', function () {
stopLoop();
// 지금 색이 맨 앞에 끼어들고 아홉 번째는 잘려 나간다. 저장한 뒤 다시 읽어 칸을 그린다
var next = [palette[step]].concat(recent.filter(function (hex) { return hex !== palette[step]; }));
save(next.slice(0, 8));
recent = read();
paint();
});
cells.forEach(function (cell, i) {
cell.addEventListener('click', function () {
stopLoop();
var at = palette.indexOf(recent[i]);
if (at >= 0) { step = at; }
paint();
});
});
paint();
})();// RecentHistory — vanilla 판(run/286/demo/05-color-picker-recent-history) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 칸 색은 등록 속성 --c 하나로만 들어간다. 사람이 쓸 때는 인라인 스타일로,
// 격자 미리보기에서는 module.scss 의 키프레임이 같은 --c 를 덮어쓴다 — 한 경로로 둘 다 된다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RecentHistory.module.scss";
export type RecentHistoryProps = {
addLabel?: string;
capLabel?: string;
/** "지금 색 %x, 누르면 다음 색" 처럼 자리표시자 하나를 가진 틀 */
nowFormat?: string;
/** "최근 색 %x" 처럼 자리표시자 하나를 가진 틀 */
cellFormat?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
type Swatch = CSSProperties & { "--c"?: string };
const PALETTE = ["#2F6DF6", "#6EC8FF", "#4CD4A6", "#FFD23F", "#FF4D1F", "#FF9EBB", "#B9A0FF", "#FFF7E6"];
const STORE_KEY = "godrich-recent-colors";
// 목록은 localStorage 에 남는다. 저장소가 막힌 환경에서도 기본 팔레트로 이어간다
function read(): string[] {
try {
const raw = window.localStorage.getItem(STORE_KEY);
const list: string[] | null = raw ? JSON.parse(raw) : null;
if (list && list.length) return list.slice(0, 8);
} catch (err) {
/* 저장소를 못 쓰는 환경 */
}
return PALETTE.slice();
}
function save(list: string[]) {
try {
window.localStorage.setItem(STORE_KEY, JSON.stringify(list));
} catch (err) {
/* 무시 */
}
}
export default function RecentHistory({
addLabel = "담기",
capLabel = "최근 고른 색",
nowFormat = "지금 색 %x, 누르면 다음 색",
cellFormat = "최근 색 %x",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: RecentHistoryProps) {
const [live, setLive] = useState(demo);
const [step, setStep] = useState(0);
const [recent, setRecent] = useState<string[]>(PALETTE.slice());
// 서버 렌더에서는 저장소가 없으므로 붙은 뒤에 한 번 읽는다
useEffect(() => {
setRecent(read());
}, []);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const nowColor = PALETTE[step];
function add() {
setLive(false);
// 지금 색이 맨 앞에 끼어들고 아홉 번째는 잘려 나간다. 저장한 뒤 다시 읽어 칸을 그린다
const next = [nowColor].concat(recent.filter((hex) => hex !== nowColor)).slice(0, 8);
save(next);
setRecent(read());
}
function pick(hex: string) {
setLive(false);
const at = PALETTE.indexOf(hex);
if (at >= 0) setStep(at);
}
return (
<div className={[styles.rc, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div className={styles.top}>
<button
className={styles.now}
type="button"
aria-label={nowFormat.replace("%x", nowColor)}
style={{ "--c": nowColor } as Swatch}
onClick={() => {
setLive(false);
setStep((step + 1) % PALETTE.length);
}}
/>
<button className={styles.add} type="button" onClick={add}>
{addLabel}
</button>
</div>
<p className={styles.cap} aria-hidden="true">
{capLabel}
</p>
<div className={styles.hist}>
{recent.map((hex, i) => (
<button
key={`${hex}-${i}`}
className={styles.cell}
type="button"
aria-pressed={hex === nowColor}
aria-label={cellFormat.replace("%x", hex)}
style={{ "--c": hex } as Swatch}
onClick={() => pick(hex)}
/>
))}
</div>
</div>
);
}05 Recent colors: eight slots that shift forward — Measured area 5.633% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Custom Color Picker: 9 JS Parts, No Library
The post this demo lives in
Custom Color Picker: 9 JS Parts, No LibraryA custom color picker is a control for choosing a color on screen and getting a HEX code back. This page runs nine such See all demos in this post


