04Brightness, contrast and saturation sliders
Three sliders fold into one value, and the toggle swaps the edited shot with the original in the same spot.
Best forLifting a dark indoor photo, comparing before and after
brightnesscontraststeps(1, end)

<main class="stage">
<div class="ad is-demo" id="ad">
<div class="ad__frame">
<canvas class="ad__view" id="view" width="320" height="200" role="img" aria-label="Sample photo"></canvas>
<span class="ad__ghost" aria-hidden="true"></span>
<span class="ad__orig" aria-hidden="true"></span>
<span class="ad__tag">
<span class="ad__real" id="tag">Edited</span>
<span class="ad__tagDemo ad__tagDemo--edit" aria-hidden="true">Edited</span>
<span class="ad__tagDemo ad__tagDemo--orig" aria-hidden="true">Original</span>
</span>
</div>
<div class="ad__ctl">
<div class="ad__s">
<span class="ad__lab" id="labB">Brightness</span>
<div class="ad__track" data-k="b" role="slider" tabindex="0" aria-labelledby="labB" aria-valuemin="0.5" aria-valuemax="1.5" aria-valuenow="1"><span class="ad__thumb" id="thB"></span></div>
</div>
<div class="ad__s">
<span class="ad__lab" id="labC">Contrast</span>
<div class="ad__track" data-k="c" role="slider" tabindex="0" aria-labelledby="labC" aria-valuemin="0.5" aria-valuemax="1.5" aria-valuenow="1"><span class="ad__thumb" id="thC"></span></div>
</div>
<div class="ad__s">
<span class="ad__lab" id="labS">Saturation</span>
<div class="ad__track" data-k="s" role="slider" tabindex="0" aria-labelledby="labS" aria-valuemin="0.5" aria-valuemax="1.5" aria-valuenow="1"><span class="ad__thumb" id="thS"></span></div>
</div>
</div>
<div class="ad__foot">
<button type="button" class="ad__btn" id="resetBtn">Reset</button>
<button type="button" class="ad__btn ad__btn--ab" id="abBtn" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="ad__ico" aria-hidden="true"><g fill="currentColor"><path d="M80 136a24 24 0 1 1-24-24a24 24 0 0 1 24 24m48-72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m72 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M64 105V40a8 8 0 0 0-16 0v65a32 32 0 0 0 0 62v49a8 8 0 0 0 16 0v-49a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m80-95V40a8 8 0 0 0-16 0v17a32 32 0 0 0 0 62v97a8 8 0 0 0 16 0v-97a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m104 64a32.06 32.06 0 0 0-24-31V40a8 8 0 0 0-16 0v97a32 32 0 0 0 0 62v17a8 8 0 0 0 16 0v-17a32.06 32.06 0 0 0 24-31m-32 16a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg><span>Compare</span></button>
<span class="ad__codes">
<code class="ad__realCode" id="code">brightness(1.00) contrast(1.00) saturate(1.00)</code>
<code class="ad__demoCode ad__demoCode--a" aria-hidden="true">brightness(1.00) contrast(1.00) saturate(1.00)</code>
<code class="ad__demoCode ad__demoCode--b" aria-hidden="true">brightness(1.18) contrast(1.22) saturate(1.30)</code>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 밝기 대비 채도 슬라이더 — 값 셋이 filter 문자열 하나로 합쳐지고, 비교 버튼이 원본과 번갈아 보여준다.
// 격자 미리보기(.is-demo)는 유령 판 두 장을 겹쳐 쓴다. 아래 판은 filter 를 부드럽게 보간하고,
// 위의 원본 판은 steps(1, end) 로 한 프레임에 덮는다 — 교차 페이드로 하면 두 장이 반투명하게 겹친다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
.ad { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.ad__frame {
position: relative; height: clamp(76px, 38vh, 132px); aspect-ratio: 16 / 10; max-width: 100%;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.ad__view { display: block; width: 100%; height: 100%; }
.ad__ghost, .ad__orig {
display: none; position: absolute; inset: 0;
background-image: var(--photo); background-size: cover;
}
.ad__tag {
position: absolute; left: $sp-1; top: $sp-1; padding: 0 $sp-1;
border-radius: $r-xs; background: rgba(23, 20, 26, .72); color: $subject-cream;
// 세로 여백은 4px 격자에 안 맞는 2px 이었다 — 0 으로 내리고 행간으로 높이를 만든다
font-weight: 800; font-size: clamp(8px, 2.1vw, 10px); line-height: 2;
}
.ad__tagDemo { display: none; position: absolute; left: $sp-1; top: 0; }
.ad__ctl { display: flex; justify-content: center; gap: $sp-2; }
.ad__s { display: grid; justify-items: center; gap: $sp-1; width: 88px; }
.ad__lab { font-weight: 800; font-size: clamp(8px, 2.1vw, 10px); color: $color; }
.ad__track {
position: relative; width: 100%; height: 6px; cursor: pointer;
border-radius: $r-pill; background: rgba(23, 20, 26, .18);
}
.ad__track:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.ad__thumb {
position: absolute; top: 50%; left: 50%; width: 13px; height: 13px;
border-radius: 50%; background: $subject-blue; box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
.ad__foot { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; justify-content: center; }
.ad__btn {
display: inline-flex; align-items: center; gap: $sp-1; cursor: pointer;
border: 1px solid rgba(23, 20, 26, .24); background: $stage-paper; color: $color;
border-radius: $r-pill; font-family: inherit; font-weight: 700; line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px); padding: $sp-1 $sp-2;
}
.ad__btn--ab { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
.ad__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.ad__ico { width: 12px; height: 12px; flex: 0 0 auto; }
.ad__codes {
position: relative; display: inline-block; min-width: 200px; height: 14px;
font-family: ui-monospace, "Consolas", monospace; font-size: clamp(8px, 2.1vw, 10px);
color: rgba(23, 20, 26, .7);
}
.ad__demoCode { display: none; position: absolute; left: 0; top: 0; white-space: nowrap; }
// ── 미리보기 루프: 슬라이더 셋이 8~12% 에 올라가고, 47~66% 에 원본이 한 프레임 덮었다 물러나고,
// 82~86% 에 처음 값으로 내려온다. 24장 표본의 마지막 장이 2초의 95.8% 지점이라 복귀는 그 앞에 끝낸다
.ad.is-demo .ad__view { visibility: hidden; }
.ad.is-demo .ad__ghost {
display: block;
animation-name: adTone; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes adTone {
0%, 8% { filter: brightness(1) contrast(1) saturate(1); }
12%, 82% { filter: brightness(1.18) contrast(1.22) saturate(1.3); }
86%, 100% { filter: brightness(1) contrast(1) saturate(1); }
}
.ad.is-demo .ad__orig {
display: block;
animation-name: adCompare; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes adCompare {
0%, 46% { opacity: 0; }
47%, 66% { opacity: 1; }
67%, 100% { opacity: 0; }
}
.ad.is-demo .ad__real { color: transparent; }
.ad.is-demo .ad__tagDemo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.ad.is-demo .ad__tagDemo--edit { animation-name: adTagEdit; }
.ad.is-demo .ad__tagDemo--orig { animation-name: adTagOrig; }
@keyframes adTagEdit { 0%, 46% { opacity: 1; } 47%, 66% { opacity: 0; } 67%, 100% { opacity: 1; } }
@keyframes adTagOrig { 0%, 46% { opacity: 0; } 47%, 66% { opacity: 1; } 67%, 100% { opacity: 0; } }
.ad.is-demo .ad__thumb {
animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ad.is-demo #thB { animation-name: adThumbB; }
.ad.is-demo #thC { animation-name: adThumbC; }
.ad.is-demo #thS { animation-name: adThumbS; }
@keyframes adThumbB {
0%, 8% { transform: translate(-50%, -50%); }
12%, 82% { transform: translate(-50%, -50%) translateX(16px); }
86%, 100% { transform: translate(-50%, -50%); }
}
@keyframes adThumbC {
0%, 8% { transform: translate(-50%, -50%); }
12%, 82% { transform: translate(-50%, -50%) translateX(19px); }
86%, 100% { transform: translate(-50%, -50%); }
}
@keyframes adThumbS {
0%, 8% { transform: translate(-50%, -50%); }
12%, 82% { transform: translate(-50%, -50%) translateX(26px); }
86%, 100% { transform: translate(-50%, -50%); }
}
// 비교 버튼은 자기 중심에서 눌린다 — 원점을 모서리에 두면 눌리는 게 아니라 옆으로 밀려 보인다
.ad.is-demo .ad__btn--ab {
animation-name: adPress; animation-duration: $duration;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
}
@keyframes adPress {
0%, 42% { transform: scale(1); }
47% { transform: scale(0.88); }
54%, 62% { transform: scale(1); }
67% { transform: scale(0.88); }
74%, 100% { transform: scale(1); }
}
// 값 글자는 보간할 수 없으니 두 칸을 steps(1, end) 로 갈아 끼우고, 슬라이더가 옮겨 가는 구간을
// 한 프레임 안(8~12%)으로 끊는다 — 그래야 어느 장면을 잘라도 글자와 사진이 같은 값을 말한다
.ad.is-demo .ad__realCode { color: transparent; }
.ad.is-demo .ad__demoCode {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.ad.is-demo .ad__demoCode--a { animation-name: adCodeA; }
.ad.is-demo .ad__demoCode--b { animation-name: adCodeB; }
@keyframes adCodeA { 0%, 9% { opacity: 1; } 10%, 83% { opacity: 0; } 84%, 100% { opacity: 1; } }
@keyframes adCodeB { 0%, 9% { opacity: 0; } 10%, 83% { opacity: 1; } 84%, 100% { opacity: 0; } }
@media (max-width: 340px) {
.ad { gap: $sp-1; }
.ad__ctl { gap: $sp-1; }
.ad__s { width: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.ad.is-demo .ad__ghost,
.ad.is-demo .ad__orig,
.ad.is-demo .ad__tagDemo,
.ad.is-demo .ad__thumb,
.ad.is-demo .ad__btn--ab,
.ad.is-demo .ad__demoCode { animation: none !important; }
// 움직임 없이 보정을 올린 상태로 세운다
.ad.is-demo .ad__ghost { filter: brightness(1.18) contrast(1.22) saturate(1.3) !important; }
.ad.is-demo .ad__orig { opacity: 0 !important; }
.ad.is-demo .ad__tagDemo--edit { opacity: 1 !important; }
.ad.is-demo .ad__tagDemo--orig { opacity: 0 !important; }
.ad.is-demo #thB { transform: translate(-50%, -50%) translateX(16px) !important; }
.ad.is-demo #thC { transform: translate(-50%, -50%) translateX(19px) !important; }
.ad.is-demo #thS { transform: translate(-50%, -50%) translateX(26px) !important; }
.ad.is-demo .ad__demoCode--a { opacity: 0 !important; }
.ad.is-demo .ad__demoCode--b { opacity: 1 !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);
}
.ad {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.ad__frame {
position: relative;
height: clamp(76px, 38vh, 132px);
aspect-ratio: 16/10;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ad__view {
display: block;
width: 100%;
height: 100%;
}
.ad__ghost, .ad__orig {
display: none;
position: absolute;
inset: 0;
background-image: var(--photo);
background-size: cover;
}
.ad__tag {
position: absolute;
left: 4px;
top: 4px;
padding: 0 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
font-weight: 800;
font-size: clamp(8px, 2.1vw, 10px);
line-height: 2;
}
.ad__tagDemo {
display: none;
position: absolute;
left: 4px;
top: 0;
}
.ad__ctl {
display: flex;
justify-content: center;
gap: 8px;
}
.ad__s {
display: grid;
justify-items: center;
gap: 4px;
width: 88px;
}
.ad__lab {
font-weight: 800;
font-size: clamp(8px, 2.1vw, 10px);
color: #17141a;
}
.ad__track {
position: relative;
width: 100%;
height: 6px;
cursor: pointer;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.ad__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.ad__thumb {
position: absolute;
top: 50%;
left: 50%;
width: 13px;
height: 13px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.ad__foot {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.ad__btn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
border: 1px solid rgba(23, 20, 26, 0.24);
background: #ffffff;
color: #17141a;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px);
padding: 4px 8px;
}
.ad__btn--ab {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
.ad__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.ad__ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.ad__codes {
position: relative;
display: inline-block;
min-width: 200px;
height: 14px;
font-family: ui-monospace, "Consolas", monospace;
font-size: clamp(8px, 2.1vw, 10px);
color: rgba(23, 20, 26, 0.7);
}
.ad__demoCode {
display: none;
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
}
.ad.is-demo .ad__view {
visibility: hidden;
}
.ad.is-demo .ad__ghost {
display: block;
animation-name: adTone;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes adTone {
0%, 8% {
filter: brightness(1) contrast(1) saturate(1);
}
12%, 82% {
filter: brightness(1.18) contrast(1.22) saturate(1.3);
}
86%, 100% {
filter: brightness(1) contrast(1) saturate(1);
}
}
.ad.is-demo .ad__orig {
display: block;
animation-name: adCompare;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes adCompare {
0%, 46% {
opacity: 0;
}
47%, 66% {
opacity: 1;
}
67%, 100% {
opacity: 0;
}
}
.ad.is-demo .ad__real {
color: transparent;
}
.ad.is-demo .ad__tagDemo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ad.is-demo .ad__tagDemo--edit {
animation-name: adTagEdit;
}
.ad.is-demo .ad__tagDemo--orig {
animation-name: adTagOrig;
}
@keyframes adTagEdit {
0%, 46% {
opacity: 1;
}
47%, 66% {
opacity: 0;
}
67%, 100% {
opacity: 1;
}
}
@keyframes adTagOrig {
0%, 46% {
opacity: 0;
}
47%, 66% {
opacity: 1;
}
67%, 100% {
opacity: 0;
}
}
.ad.is-demo .ad__thumb {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ad.is-demo #thB {
animation-name: adThumbB;
}
.ad.is-demo #thC {
animation-name: adThumbC;
}
.ad.is-demo #thS {
animation-name: adThumbS;
}
@keyframes adThumbB {
0%, 8% {
transform: translate(-50%, -50%);
}
12%, 82% {
transform: translate(-50%, -50%) translateX(16px);
}
86%, 100% {
transform: translate(-50%, -50%);
}
}
@keyframes adThumbC {
0%, 8% {
transform: translate(-50%, -50%);
}
12%, 82% {
transform: translate(-50%, -50%) translateX(19px);
}
86%, 100% {
transform: translate(-50%, -50%);
}
}
@keyframes adThumbS {
0%, 8% {
transform: translate(-50%, -50%);
}
12%, 82% {
transform: translate(-50%, -50%) translateX(26px);
}
86%, 100% {
transform: translate(-50%, -50%);
}
}
.ad.is-demo .ad__btn--ab {
animation-name: adPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes adPress {
0%, 42% {
transform: scale(1);
}
47% {
transform: scale(0.88);
}
54%, 62% {
transform: scale(1);
}
67% {
transform: scale(0.88);
}
74%, 100% {
transform: scale(1);
}
}
.ad.is-demo .ad__realCode {
color: transparent;
}
.ad.is-demo .ad__demoCode {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ad.is-demo .ad__demoCode--a {
animation-name: adCodeA;
}
.ad.is-demo .ad__demoCode--b {
animation-name: adCodeB;
}
@keyframes adCodeA {
0%, 9% {
opacity: 1;
}
10%, 83% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@keyframes adCodeB {
0%, 9% {
opacity: 0;
}
10%, 83% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ad {
gap: 4px;
}
.ad__ctl {
gap: 4px;
}
.ad__s {
width: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.ad.is-demo .ad__ghost,
.ad.is-demo .ad__orig,
.ad.is-demo .ad__tagDemo,
.ad.is-demo .ad__thumb,
.ad.is-demo .ad__btn--ab,
.ad.is-demo .ad__demoCode {
animation: none !important;
}
.ad.is-demo .ad__ghost {
filter: brightness(1.18) contrast(1.22) saturate(1.3) !important;
}
.ad.is-demo .ad__orig {
opacity: 0 !important;
}
.ad.is-demo .ad__tagDemo--edit {
opacity: 1 !important;
}
.ad.is-demo .ad__tagDemo--orig {
opacity: 0 !important;
}
.ad.is-demo #thB {
transform: translate(-50%, -50%) translateX(16px) !important;
}
.ad.is-demo #thC {
transform: translate(-50%, -50%) translateX(19px) !important;
}
.ad.is-demo #thS {
transform: translate(-50%, -50%) translateX(26px) !important;
}
.ad.is-demo .ad__demoCode--a {
opacity: 0 !important;
}
.ad.is-demo .ad__demoCode--b {
opacity: 1 !important;
}
}var ad = document.getElementById('ad');
var view = document.getElementById('view');
var tag = document.getElementById('tag');
var code = document.getElementById('code');
var abBtn = document.getElementById('abBtn');
var tracks = document.querySelectorAll('.ad__track');
var thumbs = { b: document.getElementById('thB'), c: document.getElementById('thC'), s: document.getElementById('thS') };
var value = { b: 1, c: 1, s: 1 };
var showOriginal = false;
var VOICE = '%v times';
var TAG_EDIT = 'Edited';
var TAG_ORIG = 'Original';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function render() {
// 슬라이더 셋이 filter 문자열 하나로 합쳐진다 — 순서가 곧 적용 순서다
var chain = 'brightness(' + value.b.toFixed(2) + ') contrast(' + value.c.toFixed(2) + ') saturate(' + value.s.toFixed(2) + ')';
view.style.filter = showOriginal ? 'none' : chain;
code.textContent = chain;
tag.textContent = showOriginal ? TAG_ORIG : TAG_EDIT;
for (var i = 0; i < tracks.length; i++) {
var key = tracks[i].getAttribute('data-k'), v = value[key];
thumbs[key].style.left = ((v - 0.5) * 100) + '%';
tracks[i].setAttribute('aria-valuenow', v.toFixed(2));
tracks[i].setAttribute('aria-valuetext', VOICE.replace('%v', v.toFixed(2)));
}
}
function setValue(key, next) {
value[key] = Math.max(0.5, Math.min(1.5, Math.round(next * 100) / 100));
ad.classList.remove('is-demo');
render();
}
for (var i = 0; i < tracks.length; i++) {
(function (el) {
var key = el.getAttribute('data-k');
function fromPointer(e) {
var r = el.getBoundingClientRect();
setValue(key, (e.clientX - r.left) / r.width + 0.5);
}
el.addEventListener('pointerdown', function (e) { el.setPointerCapture(e.pointerId); fromPointer(e); });
el.addEventListener('pointermove', function (e) { if (e.buttons) { fromPointer(e); } });
// 화살표 0.02, Shift 와 함께면 0.1 — 눈으로 맞추기 어려운 미세 보정은 키보드가 낫다
el.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 0.1 : 0.02;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setValue(key, value[key] - step); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setValue(key, value[key] + step); }
});
})(tracks[i]);
}
document.getElementById('resetBtn').addEventListener('click', function () {
value.b = 1; value.c = 1; value.s = 1; showOriginal = false;
abBtn.setAttribute('aria-pressed', 'false');
ad.classList.remove('is-demo'); render();
});
abBtn.addEventListener('click', function () {
showOriginal = !showOriginal;
abBtn.setAttribute('aria-pressed', showOriginal ? 'true' : 'false');
ad.classList.remove('is-demo'); render();
});
paintPhoto(view.getContext('2d'), view.width, view.height);
document.documentElement.style.setProperty('--photo', 'url(' + view.toDataURL() + ')');
render();// ToneSliders — vanilla 판(run/295/demo/04-image-edit-adjust-sliders/index.html) 의 React 포트.
// 밝기·대비·채도 세 값이 filter 문자열 하나로 합쳐져 canvas 에 걸리고, 비교 버튼은 filter 를 none 으로
// 돌려 원본을 보여준다. 가짜 사진은 paintPhoto 가 그려 넣고, 그 toDataURL 을 --photo 로 내려
// 미리보기 유령 판 두 장이 같은 그림을 쓴다. 값·수치·키프레임은 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ToneSliders.module.scss";
export type ToneSlidersProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--photo"?: string;
};
type ToneKey = "b" | "c" | "s";
type Tone = { b: number; c: number; s: number };
// react 앰비언트에 KeyboardEvent 가 없다 — 키 핸들러가 실제로 쓰는 것만 지역 타입으로 선언한다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const VOICE = "%v배";
const TAG_EDIT = "보정 후";
const TAG_ORIG = "원본";
const SLIDERS: { key: ToneKey; label: string; labelId: string; thumb: string }[] = [
{ key: "b", label: "밝기", labelId: "labB", thumb: styles.thumbB },
{ key: "c", label: "대비", labelId: "labC", thumb: styles.thumbC },
{ key: "s", label: "채도", labelId: "labS", thumb: styles.thumbS },
];
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6"); sky.addColorStop(0.55, "#ff9ebb"); sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)"; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = "#c2361a";
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (let i = 0; i < 6; i++) { c.fillStyle = i % 2 ? "#fff7e6" : "#17141a"; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
export default function ToneSliders({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: ToneSlidersProps) {
const viewRef = useRef<HTMLCanvasElement | null>(null);
const [tone, setTone] = useState<Tone>({ b: 1, c: 1, s: 1 });
const [showOriginal, setShowOriginal] = useState(false);
const [demo, setDemo] = useState(true);
const [photo, setPhoto] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (photo) vars["--photo"] = `url(${photo})`;
useEffect(() => {
const canvas = viewRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
paintPhoto(ctx, canvas.width, canvas.height);
setPhoto(canvas.toDataURL());
}, []);
// 슬라이더 셋이 filter 문자열 하나로 합쳐진다 — 순서가 곧 적용 순서다
const chain = `brightness(${tone.b.toFixed(2)}) contrast(${tone.c.toFixed(2)}) saturate(${tone.s.toFixed(2)})`;
function setValue(key: ToneKey, nextRaw: number): void {
const v = Math.max(0.5, Math.min(1.5, Math.round(nextRaw * 100) / 100));
setTone((prev) => {
const next: Tone = { b: prev.b, c: prev.c, s: prev.s };
next[key] = v;
return next;
});
setDemo(false);
}
function fromPointer(key: ToneKey, event: ReactPointerEvent<HTMLDivElement>): void {
const r = event.currentTarget.getBoundingClientRect();
setValue(key, (event.clientX - r.left) / r.width + 0.5);
}
function onPointerDown(key: ToneKey, event: ReactPointerEvent<HTMLDivElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
fromPointer(key, event);
}
function onPointerMove(key: ToneKey, event: ReactPointerEvent<HTMLDivElement>): void {
if (event.buttons) { fromPointer(key, event); }
}
// 화살표 0.02, Shift 와 함께면 0.1 — 눈으로 맞추기 어려운 미세 보정은 키보드가 낫다
function onKeyDown(key: ToneKey, event: KeyEvt): void {
const step = event.shiftKey ? 0.1 : 0.02;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") { event.preventDefault(); setValue(key, tone[key] - step); }
else if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault(); setValue(key, tone[key] + step); }
}
function onReset(): void {
setTone({ b: 1, c: 1, s: 1 });
setShowOriginal(false);
setDemo(false);
}
function onCompare(): void {
setShowOriginal(!showOriginal);
setDemo(false);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.frame}>
<canvas className={styles.view} ref={viewRef} width={320} height={200} role="img" aria-label="샘플 사진" style={{ filter: showOriginal ? "none" : chain }}></canvas>
<span className={styles.ghost} aria-hidden="true"></span>
<span className={styles.orig} aria-hidden="true"></span>
<span className={styles.tag}>
<span className={styles.real}>{showOriginal ? TAG_ORIG : TAG_EDIT}</span>
<span className={`${styles.tagDemo} ${styles.tagDemoEdit}`} aria-hidden="true">보정 후</span>
<span className={`${styles.tagDemo} ${styles.tagDemoOrig}`} aria-hidden="true">원본</span>
</span>
</div>
<div className={styles.ctl}>
{SLIDERS.map((s) => (
<div className={styles.slider} key={s.key}>
<span className={styles.lab} id={s.labelId}>{s.label}</span>
<div
className={styles.track}
role="slider"
tabIndex={0}
aria-labelledby={s.labelId}
aria-valuemin={0.5}
aria-valuemax={1.5}
aria-valuenow={Number(tone[s.key].toFixed(2))}
aria-valuetext={VOICE.replace("%v", tone[s.key].toFixed(2))}
onPointerDown={(e: ReactPointerEvent<HTMLDivElement>) => onPointerDown(s.key, e)}
onPointerMove={(e: ReactPointerEvent<HTMLDivElement>) => onPointerMove(s.key, e)}
onKeyDown={(e: KeyEvt) => onKeyDown(s.key, e)}
>
<span className={`${styles.thumb} ${s.thumb}`} style={{ left: `${(tone[s.key] - 0.5) * 100}%` }}></span>
</div>
</div>
))}
</div>
<div className={styles.foot}>
<button type="button" className={styles.btn} onClick={onReset}>초기화</button>
<button type="button" className={`${styles.btn} ${styles.btnAb}`} aria-pressed={showOriginal} onClick={onCompare}><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M80 136a24 24 0 1 1-24-24a24 24 0 0 1 24 24m48-72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m72 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M64 105V40a8 8 0 0 0-16 0v65a32 32 0 0 0 0 62v49a8 8 0 0 0 16 0v-49a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m80-95V40a8 8 0 0 0-16 0v17a32 32 0 0 0 0 62v97a8 8 0 0 0 16 0v-97a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m104 64a32.06 32.06 0 0 0-24-31V40a8 8 0 0 0-16 0v97a32 32 0 0 0 0 62v17a8 8 0 0 0 16 0v-17a32.06 32.06 0 0 0 24-31m-32 16a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg><span>비교</span></button>
<span className={styles.codes}>
<code className={styles.realCode}>{chain}</code>
<code className={`${styles.demoCode} ${styles.demoCodeA}`} aria-hidden="true">brightness(1.00) contrast(1.00) saturate(1.00)</code>
<code className={`${styles.demoCode} ${styles.demoCodeB}`} aria-hidden="true">brightness(1.18) contrast(1.22) saturate(1.30)</code>
</span>
</div>
</div>
</main>
);
}04 Brightness, contrast and saturation sliders — Measured area 13.291% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · Image Crop JavaScript: 9 Canvas Editor UIs
The post this demo lives in
Image Crop JavaScript: 9 Canvas Editor UIsAn image crop JavaScript widget picks out the part of a photo worth keeping and throws the rest away. These nine do it w See all demos in this post


