05Rotate and flip toolbar
One button adds 90 degrees to the rotate angle, the other flips the sign of scaleX to mirror the picture. The pressed state lives in aria-pressed as well as in the color, so a screen reader can tell it is mirrored.
Best forUpload screens where sideways scans and receipts must be straightened
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="Receipt scan captured sideways"></div></div>
<div class="rf__bar">
<button class="rf__btn" id="rf-turn" type="button" aria-label="Rotate 90 degrees"><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="Flip horizontally"><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 Rotate and flip toolbar — Measured area 11.597% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · 9 Image Zoom Viewer Interactions — No Photo Files
The post this demo lives in
9 Image Zoom Viewer Interactions — No Photo FilesAn image zoom viewer is the layer between a picture and the person studying it: it magnifies, turns, or lights up one pa See all demos in this post
If you like this effect - similar demos
Galleries06 Autoplay slideshow with a progress bar and a pause buttonCSS Image Slider — 9 Responsive Sliders, No GSAP
Galleries06 Card Flip Swept by a Ripple WaveImage Ripple Effect CSS: 9 Canvas Wave Patterns
Galleries04 Smoke That Fades and Dissolves the Farther It Drifts From Its SourceCSS Smoke Effect: 9 Canvas Noise Patterns