06Rotate Snap Control That Locks the Angle to 90-Degree Steps
Each press raises the target angle by 90 degrees, the card rotates continuously toward it, and the aspect ratio is recalculated at every angle it passes through.
Best forCorrecting a sideways-scanned document
ROTATE_SNAP_DEGboxSizerotateBy

<main class="stage">
<div class="pvr is-demo" id="pvrRoot">
<div class="pvr__frame" id="pvrFrame">
<div class="pvr__page" id="pvrPage"></div>
</div>
<div class="pvr__controls">
<button class="pvr__btn" id="pvrLeft" type="button" aria-label="Rotate left">↺</button>
<span class="pvr__deg" id="pvrDeg">0°</span>
<button class="pvr__btn" id="pvrRight" type="button" aria-label="Rotate right">↻</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.pvr {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-4;
}
.pvr__frame {
position: relative;
transition: none;
}
.pvr__page {
position: absolute;
left: 50%;
top: 50%;
background: $subject-cream;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-3;
box-sizing: border-box;
will-change: transform;
}
.pvr__title { height: 9px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pvr__line { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pvr__fig { height: 26%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvr__controls {
display: flex;
align-items: center;
gap: $sp-3;
}
.pvr__btn {
border: none;
border-radius: $r-pill;
width: 34px;
height: 34px;
font-size: 16px;
background: $subject-ink;
color: #fff;
cursor: pointer;
}
.pvr__deg {
min-width: 40px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: $subject-ink;
}
@media (max-width: 340px) {
.pvr__btn { width: 28px; height: 28px; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.pvr__page, .pvr__frame { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvr {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.pvr__frame {
position: relative;
transition: none;
}
.pvr__page {
position: absolute;
left: 50%;
top: 50%;
background: #fff7e6;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px;
box-sizing: border-box;
will-change: transform;
}
.pvr__title {
height: 9px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pvr__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pvr__fig {
height: 26%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvr__controls {
display: flex;
align-items: center;
gap: 12px;
}
.pvr__btn {
border: none;
border-radius: 999px;
width: 34px;
height: 34px;
font-size: 16px;
background: #17141a;
color: #fff;
cursor: pointer;
}
.pvr__deg {
min-width: 40px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: #17141a;
}
@media (max-width: 340px) {
.pvr__btn {
width: 28px;
height: 28px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvr__page, .pvr__frame {
transition: none;
}
}(function () {
// 기법: 버튼을 누른 횟수 × ROTATE_SNAP_DEG 로 목표 각도를 계산하고, 현재 각도를 매 프레임
// 그 목표로 보간(lerp)한다. 회전 중인 각도로 "회전된 사각형의 바운딩박스" 폭·높이를
// 매 프레임 다시 계산해 프레임 자체 크기를 바꿔가며 다시 그린다 — 90도를 넘길 때
// 가로세로가 뚝 끊겨 바뀌는 게 아니라 각도에 비례해 연속적으로 바뀐다.
var root = document.getElementById('pvrRoot');
var frame = document.getElementById('pvrFrame');
var page = document.getElementById('pvrPage');
var degEl = document.getElementById('pvrDeg');
var btnL = document.getElementById('pvrLeft');
var btnR = document.getElementById('pvrRight');
// 변형 06: ROTATE_SNAP_DEG 90 → 45 (전값이 소스 초기값)
var ROTATE_SNAP_DEG = 90;
var LOOP_MS = 2000;
var PAGE_W = 88, PAGE_H = 70;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
(function paint() {
var rnd = seedRand(430061);
var html = '<div class="pvr__title" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (var i = 0; i < 5; i += 1) html += '<div class="pvr__line" style="width:' + (38 + rnd() * 54) + '%"></div>';
html += '<div class="pvr__fig"></div>';
page.innerHTML = html;
})();
function boxSize(angleDeg, w, h) {
var r = angleDeg * Math.PI / 180;
var c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
var target = 0, current = 0;
function render() {
var box = boxSize(current, PAGE_W, PAGE_H);
frame.style.width = box.w + 'px';
frame.style.height = box.h + 'px';
page.style.width = PAGE_W + 'px';
page.style.height = PAGE_H + 'px';
page.style.transform = 'translate(-50%,-50%) rotate(' + current.toFixed(1) + 'deg)';
degEl.textContent = Math.round(((current % 360) + 360) % 360) + '°';
}
function rotateBy(dir) { target += dir * ROTATE_SNAP_DEG; }
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
btnL.addEventListener('click', function () { markActive(); rotateBy(-1); });
btnR.addEventListener('click', function () { markActive(); rotateBy(1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === '[') { e.preventDefault(); markActive(); rotateBy(-1); }
else if (e.key === 'ArrowRight' || e.key === ']') { e.preventDefault(); markActive(); rotateBy(1); }
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; current = target = 0; render(); }
var fired = {};
var SCHEDULE = [
{ t: 150, run: function () { rotateBy(1); } },
{ t: 700, run: function () { rotateBy(1); } },
{ t: 1250, run: function () { rotateBy(-1); } },
{ t: 1650, run: function () { rotateBy(-1); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
current += (target - current) * 0.18;
render();
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerRotateSnapControl.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerRotateSnapControl() {
const rootRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const degRef = useRef<HTMLSpanElement>(null);
const btnLRef = useRef<HTMLButtonElement>(null);
const btnRRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current, frame = frameRef.current, page = pageRef.current;
const degEl = degRef.current, btnL = btnLRef.current, btnR = btnRRef.current;
if (!root || !frame || !page || !degEl || !btnL || !btnR) return;
// 변형 06: ROTATE_SNAP_DEG 90 → 45 (전값이 소스 초기값)
const ROTATE_SNAP_DEG = 90;
const LOOP_MS = 2000;
const PAGE_W = 88, PAGE_H = 70;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430061);
let html = '<div class="' + styles.title + '" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (let i = 0; i < 5; i += 1) html += '<div class="' + styles.line + '" style="width:' + (38 + rnd() * 54) + '%"></div>';
html += '<div class="' + styles.fig + '"></div>';
page.innerHTML = html;
function boxSize(angleDeg: number, w: number, h: number) {
const r = angleDeg * Math.PI / 180;
const c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
let target = 0, current = 0;
function render() {
const box = boxSize(current, PAGE_W, PAGE_H);
frame!.style.width = box.w + "px";
frame!.style.height = box.h + "px";
page!.style.width = PAGE_W + "px";
page!.style.height = PAGE_H + "px";
page!.style.transform = "translate(-50%,-50%) rotate(" + current.toFixed(1) + "deg)";
degEl!.textContent = Math.round(((current % 360) + 360) % 360) + "°";
}
function rotateBy(dir: number) { target += dir * ROTATE_SNAP_DEG; }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onLeftClick() { markActive(); rotateBy(-1); }
function onRightClick() { markActive(); rotateBy(1); }
btnL.addEventListener("click", onLeftClick);
btnR.addEventListener("click", onRightClick);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowLeft" || e.key === "[") { e.preventDefault(); markActive(); rotateBy(-1); }
else if (e.key === "ArrowRight" || e.key === "]") { e.preventDefault(); markActive(); rotateBy(1); }
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; current = target = 0; render(); }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 150, run: () => { rotateBy(1); } },
{ t: 700, run: () => { rotateBy(1); } },
{ t: 1250, run: () => { rotateBy(-1); } },
{ t: 1650, run: () => { rotateBy(-1); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
current += (target - current) * 0.18;
render();
raf = requestAnimationFrame(tick);
}
render();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btnL.removeEventListener("click", onLeftClick);
btnR.removeEventListener("click", onRightClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvr} is-demo`} ref={rootRef}>
<div className={styles.frame} ref={frameRef}>
<div className={styles.page} ref={pageRef} />
</div>
<div className={styles.controls}>
<button className={styles.btn} ref={btnLRef} type="button" aria-label="왼쪽으로 회전">↺</button>
<span className={styles.deg} ref={degRef}>0°</span>
<button className={styles.btn} ref={btnRRef} type="button" aria-label="오른쪽으로 회전">↻</button>
</div>
</div>
</main>
);
}06 Rotate Snap Control That Locks the Angle to 90-Degree Steps — Measured area 11.449% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · PDF Viewer Implementation: 9 UI Patterns
The post this demo lives in
PDF Viewer Implementation: 9 UI Patternspdf viewer implementation: Does scroll decide the page number, or the reverse? Item 02's zoom center uses pointer coordi See all demos in this post
If you like this effect - similar demos
Embeds02 Tabs that swap the same example per language9 Code Block UI Patterns — No Highlighter Library
Embeds04 Social mention cards that roll in from the side9 Activity Feed UI Widgets — Sidebar-Ready Lists
Embeds07 Precipitation bars that grow, and a compass that points into the windWeather Widget Design — 9 Widgets, No Library