04범위 밖 흐림 전환 — 허용 범위 값을 벗어난 날짜의 흐림 값이 커져 고를 수 없는 상태로 바뀌는 방식
허용 범위 값 밖의 날짜 칸은 흐림 값이 커지고 눌림 반응 값이 사라져, 나머지 칸과 다른 상태임이 눈에 드러납니다.
쓰임예약 가능 기간 제한, 유효기간이 있는 쿠폰 사용일 선택
FADE_STEPFADE_FLOORrecompute()

<main class="stage">
<div class="mf is-demo" id="mf">
<div class="mf__head"><span class="mf__label">2026년 3월</span></div>
<div class="mf__weekday" aria-hidden="true">
<span>일</span><span>월</span><span>화</span><span>수</span><span>목</span><span>금</span><span>토</span>
</div>
<div class="mf__grid" id="mfGrid"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: recompute() 가 각 날짜와 최소·최대 경계 사이의 거리를 계산해
// 범위 밖 셀의 opacity 를 그 거리만큼 낮춘다(선형 감쇠, 하한 있음).
@include stage($stage-paper);
.mf {
width: 100%;
max-width: 220px;
background: $subject-cream;
border-radius: $r-card;
padding: $sp-2;
box-shadow: $shadow-raised;
box-sizing: border-box;
}
.mf__head {
text-align: center;
margin-bottom: $sp-1;
}
.mf__label {
font-size: 11px;
font-weight: 700;
color: $subject-ink;
}
.mf__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: $sp-1;
}
.mf__weekday span {
font-size: 9px;
text-align: center;
color: rgba($subject-ink, .72);
}
.mf__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
}
.mf__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: $subject-ink;
border-radius: $r-xs;
cursor: pointer;
transition: opacity $dur-base $ease-out;
}
.mf__cell--out {
visibility: hidden;
}
.mf__cell--out-range {
cursor: default;
}
.mf__cell--bound {
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700;
}
@media (max-width: 340px) {
.mf { max-width: 180px; }
.mf__grid { grid-auto-rows: 17px; }
.mf__cell { font-size: 10px; }
.mf__weekday span { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.mf__cell { transition: none; }
}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);
}
.mf {
width: 100%;
max-width: 220px;
background: #fff7e6;
border-radius: 16px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.mf__head {
text-align: center;
margin-bottom: 4px;
}
.mf__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.mf__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.mf__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
}
.mf__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
}
.mf__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: #17141a;
border-radius: 4px;
cursor: pointer;
transition: opacity 300ms ease-out;
}
.mf__cell--out {
visibility: hidden;
}
.mf__cell--out-range {
cursor: default;
}
.mf__cell--bound {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
}
@media (max-width: 340px) {
.mf {
max-width: 180px;
}
.mf__grid {
grid-auto-rows: 17px;
}
.mf__cell {
font-size: 10px;
}
.mf__weekday span {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.mf__cell {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42;
var FADE_STEP = .12; // 계산 겹: 경계에서 하루 멀어질 때마다 불투명도가 이만큼 줄어든다
var FADE_FLOOR = .15; // 계산 겹: 아무리 멀어도 이 아래로는 안 내려간다
var RANGE_SEQ = [[5, 20], [10, 25], [1, 12], [17, 29], [5, 20]]; // 계산 겹: 자동재생이 도는 최소·최대 순서
function isLeap(y) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }
function daysInMonth(y, m) {
var t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y, m, d) { // Zeller 합동식 — 0=일 ~ 6=토
var yy = y, mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
var K = yy % 100, J = Math.floor(yy / 100);
var h = (d + Math.floor(13 * (mm + 1) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
var mf = document.getElementById('mf');
var grid = document.getElementById('mfGrid');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var cellByDay = {};
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'mf__cell mf__cell--out';
grid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
cell = document.createElement('button');
cell.type = 'button';
cell.className = 'mf__cell';
cell.textContent = String(d);
cell.dataset.day = String(d);
grid.appendChild(cell);
cellByDay[d] = cell;
}
var trail = CELL_TOTAL - lead - n;
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'mf__cell mf__cell--out';
grid.appendChild(cell);
}
var min = 5, max = 20, pickStage = 'min';
function recompute() {
// 계산 겹: 범위 밖 각 셀은 경계로부터의 거리만큼 불투명도를 계산해 낮춘다
for (var day = 1; day <= n; day++) {
var c = cellByDay[day];
var distOut = day < min ? (min - day) : (day > max ? (day - max) : 0);
var opacity = distOut === 0 ? 1 : Math.max(FADE_FLOOR, 1 - distOut * FADE_STEP);
c.style.opacity = String(opacity);
c.classList.toggle('mf__cell--bound', day === min || day === max);
c.classList.toggle('mf__cell--out-range', distOut > 0);
}
}
function human() {
mf.classList.remove('is-demo');
stopAutoplay();
}
grid.addEventListener('click', function (e) {
var cellEl = e.target.closest('.mf__cell');
if (!cellEl || !cellEl.dataset.day) return;
human();
var day = parseInt(cellEl.dataset.day, 10);
if (pickStage === 'min') {
min = day; max = day; pickStage = 'max';
} else if (day >= min) {
max = day; pickStage = 'min';
} else {
min = day; max = day;
}
recompute();
});
var seqIdx = 0;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() {
seqIdx = (seqIdx + 1) % RANGE_SEQ.length;
min = RANGE_SEQ[seqIdx][0];
max = RANGE_SEQ[seqIdx][1];
recompute();
}
recompute();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 700); }
})();import { useEffect, useState } from 'react';
import styles from './DatePickerMinMaxFade.module.scss';
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const FADE_STEP = .12; // 계산 겹: 경계에서 하루 멀어질 때마다 불투명도가 이만큼 줄어든다
const FADE_FLOOR = .15; // 계산 겹: 아무리 멀어도 이 아래로는 안 내려간다
const RANGE_SEQ: [number, number][] = [[5, 20], [10, 25], [1, 12], [17, 29], [5, 20]]; // 계산 겹: 자동재생이 도는 최소·최대 순서
const WEEKDAY = ['일', '월', '화', '수', '목', '금', '토'];
function isLeap(y: number) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
}
function daysInMonth(y: number, m: number) {
const t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y: number, m: number, d: number) { // Zeller 합동식 — 0=일 ~ 6=토
let yy = y;
let mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
const K = yy % 100;
const J = Math.floor(yy / 100);
let h = (d + Math.floor((13 * (mm + 1)) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
const CELL_TOTAL = 42;
const N_DAYS = daysInMonth(BASE.y, BASE.m);
const LEAD = dow(BASE.y, BASE.m, 1);
const TRAIL = CELL_TOTAL - LEAD - N_DAYS;
function fadeOf(day: number, min: number, max: number) {
// 계산 겹: 범위 밖 각 셀은 경계로부터의 거리만큼 불투명도를 계산해 낮춘다
const distOut = day < min ? min - day : day > max ? day - max : 0;
return distOut === 0 ? 1 : Math.max(FADE_FLOOR, 1 - distOut * FADE_STEP);
}
export default function DatePickerMinMaxFade() {
const [isDemo, setIsDemo] = useState(true);
const [min, setMin] = useState(5);
const [max, setMax] = useState(20);
const [pickStage, setPickStage] = useState<'min' | 'max'>('min');
function human() {
setIsDemo(false);
}
function onCellClick(day: number) {
human();
if (pickStage === 'min') {
setMin(day); setMax(day); setPickStage('max');
} else if (day >= min) {
setMax(day); setPickStage('min');
} else {
setMin(day); setMax(day);
}
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isDemo || reduce) return;
let seqIdx = 0;
const timer = window.setInterval(() => {
seqIdx = (seqIdx + 1) % RANGE_SEQ.length;
setMin(RANGE_SEQ[seqIdx][0]);
setMax(RANGE_SEQ[seqIdx][1]);
}, 700);
return () => window.clearInterval(timer);
}, [isDemo]);
const lead = Array.from({ length: LEAD }, (_, i) => <span key={'l' + i} className={`${styles.mfCell} ${styles.mfCellOut}`} />);
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
const opacity = fadeOf(d, min, max);
const bound = d === min || d === max;
return (
<button
type="button"
key={'d' + d}
className={`${styles.mfCell} ${bound ? styles.mfCellBound : ''} ${opacity < 1 ? styles.mfCellOutRange : ''}`}
style={{ opacity }}
onClick={() => onCellClick(d)}
>
{d}
</button>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => <span key={'t' + i} className={`${styles.mfCell} ${styles.mfCellOut}`} />);
return (
<div className={`${styles.mf} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.mfHead}><span className={styles.mfLabel}>2026년 3월</span></div>
<div className={styles.mfWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.mfGrid}>{lead}{days}{trail}</div>
</div>
);
}04 범위 밖 흐림 전환 — 허용 범위 값을 벗어난 날짜의 흐림 값이 커져 고를 수 없는 상태로 바뀌는 방식 — 측정 누적면적 4.213% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 날짜 선택 UI 9종 — 팝오버로 열고 접는 아홉 가지 방식
이 데모가 속한 글
날짜 선택 UI 9종 — 팝오버로 열고 접는 아홉 가지 방식날짜 선택 UI는 입력 하나로 달력이 열리고 접히는 인터랙션인데, 아홉 개 중 하나는 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고, 하나는 허용 범위 밖 날짜의 흐림 값이 커져 선택할 수 없는 칸이 드러나며, 이 글의 데모 전부 보기


