04마스킹 테이프로 붙인 스티커
카드 모서리 두 곳에 반투명 테이프 조각을 비스듬히 얹어 놓아, 진짜로 손으로 테이프를 붙여 고정한 스크랩북처럼 보이게 한다.
쓰임다이어리·무드보드 스타일 섹션
box-shadowtransform-originrequestAnimationFrame

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="cardZone">
<div class="card" id="card">
<span class="cardMark" aria-hidden="true"></span>
</div>
<span class="tape tape--a" id="tapeA" aria-hidden="true"></span>
<span class="tape tape--b" id="tapeB" aria-hidden="true"></span>
</div>
<button class="pinBtn" id="pinBtn" type="button" aria-pressed="false">테이프 한쪽 떼기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 tapeAngle(i) 에 있다 — 두 테이프의 기울기를 base + i*step 산술식으로
// 얻고(하드코딩 개별값 아님), 카드는 그 자체가 아니라 떼어짐 진행도(shown)에 비례해 회전·이동한다.
// 여기는 카드·테이프의 모양과 위치만 담당한다.
@include stage($stage-paper);
.wrap {
display: grid;
place-items: center;
gap: $sp-4;
}
.cardZone {
position: relative;
width: 168px;
height: 168px;
}
.card {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $subject-blue;
box-shadow: $shadow-raised;
transform-origin: 20% 80%; // 오른쪽 위 테이프에 붙들려 왼쪽 아래를 축으로 기운다
display: grid;
place-items: center;
}
.cardMark {
width: 56px;
height: 56px;
border-radius: 50%;
border: 8px solid rgba($subject-cream, .85);
}
.tape {
position: absolute;
width: 84px;
height: 30px;
background: rgba($subject-cream, .55);
border: 1px solid rgba($subject-ink, .08);
box-shadow: 0 2px 4px rgba($subject-ink, .12);
}
.tape--a { top: -10px; left: -18px; }
.tape--b { bottom: -12px; right: -16px; }
.pinBtn {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-2 $sp-4;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: scale(1.03); }
&:active { transform: scale(.97); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
@media (max-width: 340px) {
.cardZone { width: 130px; height: 130px; }
.cardMark { width: 44px; height: 44px; border-width: 6px; }
.tape { width: 66px; height: 24px; }
.pinBtn { font-size: 11px; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.pinBtn { 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);
}
.wrap {
display: grid;
place-items: center;
gap: 16px;
}
.cardZone {
position: relative;
width: 168px;
height: 168px;
}
.card {
position: absolute;
inset: 0;
border-radius: 16px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: 20% 80%;
display: grid;
place-items: center;
}
.cardMark {
width: 56px;
height: 56px;
border-radius: 50%;
border: 8px solid rgba(255, 247, 230, 0.85);
}
.tape {
position: absolute;
width: 84px;
height: 30px;
background: rgba(255, 247, 230, 0.55);
border: 1px solid rgba(23, 20, 26, 0.08);
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.12);
}
.tape--a {
top: -10px;
left: -18px;
}
.tape--b {
bottom: -12px;
right: -16px;
}
.pinBtn {
appearance: none;
border: none;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 8px 16px;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: transform 140ms ease-out;
}
.pinBtn:hover {
transform: scale(1.03);
}
.pinBtn:active {
transform: scale(0.97);
}
.pinBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
@media (max-width: 340px) {
.cardZone {
width: 130px;
height: 130px;
}
.cardMark {
width: 44px;
height: 44px;
border-width: 6px;
}
.tape {
width: 66px;
height: 24px;
}
.pinBtn {
font-size: 11px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pinBtn {
transition: none;
}
}(function () {
var TAPE_BASE_ANGLE = 14; // 테이프 기울기의 시작값(deg)
var TAPE_ANGLE_STEP = 8; // 테이프마다 더해지는 기울기 증가폭(deg) — 각도는 하드코딩이 아니라 산술로 만든다
var LIFT_MAX_DEG = 10; // 완전히 떼어졌을 때 카드가 기우는 각도(deg)
var LIFT_MAX_PX = 8; // 완전히 떼어졌을 때 카드가 뜨는 높이(px)
var TAPE_STRETCH = 6; // 떼는 동안 tapeA 가 더 기울어지는 최대폭(deg)
var LERP_RATE = .17;
var STEP_MS = 550;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var tapeA = document.getElementById('tapeA');
var tapeB = document.getElementById('tapeB');
var pinBtn = document.getElementById('pinBtn');
var isDemo = true;
// 두 테이프의 기울기는 코너 순번(i)에 산술식(base + i*step)을 적용해 얻는다 — 부호는 좌우 대칭으로
// 번갈아 뒤집는다. 하드코딩된 개별 각도값이 아니라 하나의 공식에서 나온 값이라는 게 계산 겹이다.
function tapeAngle(i) {
var sign = i % 2 === 0 ? -1 : 1;
return sign * (TAPE_BASE_ANGLE + TAPE_ANGLE_STEP * i);
}
var baseAngleA = tapeAngle(1);
var baseAngleB = tapeAngle(2);
tapeB.style.transform = 'rotate(' + baseAngleB + 'deg)';
var target = 0; // 0 = 붙어 있음, 1 = 한쪽이 떼어짐
var shown = 0;
var pinned = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function toggle() {
human();
pinned = !pinned;
target = pinned ? 0 : 1;
pinBtn.setAttribute('aria-pressed', String(!pinned));
pinBtn.textContent = pinned ? '테이프 한쪽 떼기' : '다시 붙이기';
}
pinBtn.addEventListener('click', toggle);
var dir = 1, last = 0;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
target = dir > 0 ? 1 : 0;
dir *= -1;
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
card.style.transform = 'rotate(' + (LIFT_MAX_DEG * shown).toFixed(2) + 'deg) translate(' +
(LIFT_MAX_PX * shown * .6).toFixed(2) + 'px, ' + (-LIFT_MAX_PX * shown).toFixed(2) + 'px)';
tapeA.style.transform = 'rotate(' + (baseAngleA + TAPE_STRETCH * shown).toFixed(2) + 'deg) translateY(' +
(-LIFT_MAX_PX * .6 * shown).toFixed(2) + 'px)';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// TapeStrip — vanilla 판(run/378/demo/04-sticker-tape-strip/index.html) 의 React 포트.
// 두 테이프의 기울기를 base + i*step 산술식(tapeAngle)으로 얻는다 — 하드코딩 개별값이 아니다.
import { useEffect, useRef } from "react";
import styles from "./TapeStrip.module.scss";
type Box<T> = { current: T };
const TAPE_BASE_ANGLE = 14;
const TAPE_ANGLE_STEP = 8;
const LIFT_MAX_DEG = 10;
const LIFT_MAX_PX = 8;
const TAPE_STRETCH = 6;
const LERP_RATE = 0.17;
const STEP_MS = 550;
function tapeAngle(i: number) {
const sign = i % 2 === 0 ? -1 : 1;
return sign * (TAPE_BASE_ANGLE + TAPE_ANGLE_STEP * i);
}
const baseAngleA = tapeAngle(1);
const baseAngleB = tapeAngle(2);
export default function TapeStrip() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tapeARef = useRef(null) as unknown as Box<HTMLSpanElement>;
const tapeBRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const pinBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const card = cardRef.current;
const tapeA = tapeARef.current;
const tapeB = tapeBRef.current;
const pinBtn = pinBtnRef.current;
if (!wrap || !card || !tapeA || !tapeB || !pinBtn) return;
tapeB.style.transform = "rotate(" + baseAngleB + "deg)";
let isDemo = true;
let target = 0;
let shown = 0;
let pinned = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function toggle() {
human();
pinned = !pinned;
target = pinned ? 0 : 1;
pinBtn!.setAttribute("aria-pressed", String(!pinned));
pinBtn!.textContent = pinned ? "테이프 한쪽 떼기" : "다시 붙이기";
}
pinBtn.addEventListener("click", toggle);
let dir = 1, last = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
target = dir > 0 ? 1 : 0;
dir *= -1;
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
card!.style.transform = "rotate(" + (LIFT_MAX_DEG * shown).toFixed(2) + "deg) translate(" +
(LIFT_MAX_PX * shown * 0.6).toFixed(2) + "px, " + (-LIFT_MAX_PX * shown).toFixed(2) + "px)";
tapeA!.style.transform = "rotate(" + (baseAngleA + TAPE_STRETCH * shown).toFixed(2) + "deg) translateY(" +
(-LIFT_MAX_PX * 0.6 * shown).toFixed(2) + "px)";
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
pinBtn!.removeEventListener("click", toggle);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.cardZone}>
<div className={styles.card} ref={cardRef}>
<span className={styles.cardMark} aria-hidden="true"></span>
</div>
<span className={styles.tape + " " + styles.tapeA} ref={tapeARef} aria-hidden="true"></span>
<span className={styles.tape + " " + styles.tapeB} ref={tapeBRef} aria-hidden="true"></span>
</div>
<button className={styles.pinBtn} ref={pinBtnRef} type="button" aria-pressed="false">테이프 한쪽 떼기</button>
</div>
</main>
);
}04 마스킹 테이프로 붙인 스티커 — 측정 누적면적 10.047% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스티커 효과 9종 — 흰 테두리부터 홀로그램 스윕까지
이 데모가 속한 글
스티커 효과 9종 — 흰 테두리부터 홀로그램 스윕까지스티커 효과 아홉 가지 중 하나는 흰 테두리와 그림자만으로 다이컷 스티커처럼 보이게 하고, 하나는 마우스를 올리면 표면 위로 무지개빛이 각도 따라 쓸고 지나가며, 하나는 커서를 따라 살짝 기울어져 진짜 스티커를 만지 이 글의 데모 전부 보기


