05돌리고 뒤집는 툴바
버튼 하나는 rotate 각도를 90도씩 더하고, 다른 하나는 scaleX 부호를 뒤집어 좌우를 바꿉니다. 눌린 상태는 색이 아니라 aria-pressed 값으로도 남아 있어 낭독기에서도 뒤집혀 있는지 알 수 있습니다.
쓰임세로로 찍힌 스캔본·영수증을 바로 세워야 하는 업로드 화면
rotatescaleXaria-pressed

<main class="stage">
<div class="rf is-demo" id="rf">
<div class="rf__view"><div class="rf__paper" id="rf-paper" role="img" aria-label="옆으로 찍힌 영수증 스캔본"></div></div>
<div class="rf__bar">
<button class="rf__btn" id="rf-turn" type="button" aria-label="90도 돌리기"><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></svg></button>
<button class="rf__btn" id="rf-flip" type="button" aria-pressed="false" aria-label="좌우 뒤집기"><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M112 40v160a8 8 0 0 1-8 8H40a8 8 0 0 1-7.37-11.12l64-160C100 28.86 112 31.29 112 40" opacity=".2"/><path d="M107.18 24.33a15.86 15.86 0 0 0-17.92 9.45l-.06.14l-64 159.93A16 16 0 0 0 40 216h64a16 16 0 0 0 16-16V40a15.85 15.85 0 0 0-12.82-15.67M104 200H40l.06-.15L104 40Zm126.77-6.15l-64-159.93l-.06-.14A16 16 0 0 0 136 40v160a16 16 0 0 0 16 16h64a16 16 0 0 0 14.78-22.15ZM152 200V40l63.93 159.84l.06.15Z"/></g></svg></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
// 가짜 사진 — 파일 없이 그린 영수증 스캔본. 돌아간 방향이 한눈에 보이도록 세로로 길다
$receipt:
linear-gradient($stage-orange 0 0) no-repeat 50% 8% / 64% 7%,
repeating-linear-gradient(0deg, transparent 0 18%, rgba(23, 20, 26, .56) 18% 21%, transparent 21% 26%),
linear-gradient($subject-cream, $subject-cream);
.rf {
display: flex;
align-items: center;
gap: $sp-2;
}
.rf__view {
box-sizing: border-box;
display: grid;
place-items: center;
width: 180px;
height: 152px;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 247, 230, .08);
}
.rf__paper {
width: 84px;
height: 132px;
border-radius: $r-sm;
background: $receipt;
box-shadow: $shadow-press;
transform-origin: center center;
transform: rotate(0deg) scaleX(1);
}
.rf__bar {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.rf__btn {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: $r-control;
background: rgba(255, 247, 230, .14);
color: $color;
cursor: pointer;
}
.rf__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rf__btn[aria-pressed="true"] { background: $stage-yellow; color: $subject-ink; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0 → 360 도로 한 바퀴를 다 돌아 제자리로 온다. 중간에 좌우를 한 번 뒤집었다가 되돌린다.
// 뒤집기는 2% 구간(40ms)에 끝나므로 24장 미리보기에 납작해진 프레임이 남지 않는다
.rf.is-demo .rf__paper {
animation-name: rf-turn;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes rf-turn {
0% { transform: rotate(0deg) scaleX(1); }
22% { transform: rotate(90deg) scaleX(1); }
44% { transform: rotate(180deg) scaleX(1); }
46% { transform: rotate(180deg) scaleX(-1); }
66% { transform: rotate(270deg) scaleX(-1); }
84% { transform: rotate(360deg) scaleX(-1); }
86% { transform: rotate(360deg) scaleX(1); }
100% { transform: rotate(360deg) scaleX(1); }
}
@media (max-width: 360px) {
.rf__view { width: 160px; height: 144px; }
.rf__paper { width: 76px; height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rf.is-demo .rf__paper { animation: none !important; transform: rotate(90deg) scaleX(1); }
}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);
}
.rf {
display: flex;
align-items: center;
gap: 8px;
}
.rf__view {
box-sizing: border-box;
display: grid;
place-items: center;
width: 180px;
height: 152px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.08);
}
.rf__paper {
width: 84px;
height: 132px;
border-radius: 8px;
background: linear-gradient(#ff4d1f 0 0) no-repeat 50% 8%/64% 7%, repeating-linear-gradient(0deg, transparent 0 18%, rgba(23, 20, 26, 0.56) 18% 21%, transparent 21% 26%), linear-gradient(#fff7e6, #fff7e6);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-origin: center center;
transform: rotate(0deg) scaleX(1);
}
.rf__bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.rf__btn {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
cursor: pointer;
}
.rf__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rf__btn[aria-pressed=true] {
background: #ffd23f;
color: #17141a;
}
.rf.is-demo .rf__paper {
animation-name: rf-turn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes rf-turn {
0% {
transform: rotate(0deg) scaleX(1);
}
22% {
transform: rotate(90deg) scaleX(1);
}
44% {
transform: rotate(180deg) scaleX(1);
}
46% {
transform: rotate(180deg) scaleX(-1);
}
66% {
transform: rotate(270deg) scaleX(-1);
}
84% {
transform: rotate(360deg) scaleX(-1);
}
86% {
transform: rotate(360deg) scaleX(1);
}
100% {
transform: rotate(360deg) scaleX(1);
}
}
@media (max-width: 360px) {
.rf__view {
width: 160px;
height: 144px;
}
.rf__paper {
width: 76px;
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rf.is-demo .rf__paper {
animation: none !important;
transform: rotate(90deg) scaleX(1);
}
}// 실제 동작: 각도는 90도씩 더하고, 뒤집기는 scaleX 의 부호만 바꾼다.
var rf = document.getElementById('rf');
var rfPaper = document.getElementById('rf-paper');
var rfFlip = document.getElementById('rf-flip');
var rfAngle = 0, rfMirror = 1;
function rfPaint() {
rf.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
rfPaper.style.transform = 'rotate(' + rfAngle + 'deg) scaleX(' + rfMirror + ')';
rfFlip.setAttribute('aria-pressed', rfMirror === -1 ? 'true' : 'false');
}
document.getElementById('rf-turn').addEventListener('click', function () { rfAngle += 90; rfPaint(); });
rfFlip.addEventListener('click', function () { rfMirror = -rfMirror; rfPaint(); });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RotateFlipToolbar.module.scss";
export type RotateFlipToolbarProps = {
paperLabel?: string;
turnLabel?: string;
flipLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Turn = () => (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" />
</g>
</svg>
);
const Flip = () => (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M112 40v160a8 8 0 0 1-8 8H40a8 8 0 0 1-7.37-11.12l64-160C100 28.86 112 31.29 112 40" opacity=".2" />
<path d="M107.18 24.33a15.86 15.86 0 0 0-17.92 9.45l-.06.14l-64 159.93A16 16 0 0 0 40 216h64a16 16 0 0 0 16-16V40a15.85 15.85 0 0 0-12.82-15.67M104 200H40l.06-.15L104 40Zm126.77-6.15l-64-159.93l-.06-.14A16 16 0 0 0 136 40v160a16 16 0 0 0 16 16h64a16 16 0 0 0 14.78-22.15ZM152 200V40l63.93 159.84l.06.15Z" />
</g>
</svg>
);
export default function RotateFlipToolbar({
paperLabel = "Receipt scan captured sideways",
turnLabel = "Rotate 90 degrees",
flipLabel = "Flip horizontally",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: RotateFlipToolbarProps) {
const [live, setLive] = useState(demo);
const [angle, setAngle] = useState(0);
const [mirror, setMirror] = useState(1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const paper: CSSProperties = { transform: `rotate(${angle}deg) scaleX(${mirror})` };
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.view}>
<div className={styles.paper} role="img" aria-label={paperLabel} style={live ? undefined : paper} />
</div>
<div className={styles.bar}>
<button
className={styles.btn}
type="button"
aria-label={turnLabel}
onClick={() => { setLive(false); setAngle(angle + 90); }}
>
<Turn />
</button>
<button
className={styles.btn}
type="button"
aria-pressed={mirror === -1}
aria-label={flipLabel}
onClick={() => { setLive(false); setMirror(-mirror); }}
>
<Flip />
</button>
</div>
</div>
);
}05 돌리고 뒤집는 툴바 — 측정 누적면적 11.597% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어
이 데모가 속한 글
이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어이미지 확대 보기는 한 장의 그림을 크게 키우거나, 돌리거나, 한 부분만 짚어 보게 해 주는 화면 장치입니다. 아홉 개 모두 실제로 움직이며, 사진 파일은 한 장도 쓰지 않고 그라디언트로만 그렸습니다. 이 글의 데모 전부 보기


