04이미지 커튼
이미지를 덮은 색 판이 옆으로 걷히며 이미지가 드러난다.
쓰임포트폴리오, 상품 사진
주황검정위로

<main class="stage">
<div class="frame is-demo">
<div class="frame__image" aria-hidden="true"></div>
<div class="frame__curtain"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-enter; // 커튼이 걷히는 시간
$curtain: $subject-ink; // 커튼 색 (paper 무대라 ink). 주황 $subject 대체 가능
$easing: $ease-spring;
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
@include stage($stage-paper);
// ── 이미지 자리 (실제로는 <img>, 여기선 그라데이션) ─────────────
.frame {
position: relative;
width: clamp(160px, 70vw, 280px); // 320×200 에서도 세로로 안 넘치게 폭에 따라 준다
aspect-ratio: 4 / 3; // 고정 height 대신 비율로 — 폭이 줄면 높이도 같이 준다
border-radius: $r-control;
overflow: hidden;
isolation: isolate;
&__image {
position: absolute;
inset: 0;
background: linear-gradient(135deg, $stage-yellow, $stage-orange 60%, $stage-ink);
}
&__curtain {
position: absolute;
inset: 0;
background: $curtain;
transform: scaleX(1);
transform-origin: right; // 오른쪽으로 접히며 걷힌다
// ── 실제 사용 코드: 뷰포트에 들어오는 동안 커튼이 걷힌다 ──
@supports (animation-timeline: view()) {
animation: curtain-open $easing both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
// ── 미지원 브라우저: 커튼을 미리 걷어 이미지를 보여준다 ──
@supports not (animation-timeline: view()) {
transform: scaleX(0);
}
}
}
@keyframes curtain-open {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
// ── 데모(.is-demo): 스크롤 대신 2초 루프로 흉내 ────────────
.frame.is-demo .frame__curtain {
animation: curtain-loop $dur-loop $easing infinite;
}
@keyframes curtain-loop {
0% { transform: scaleX(1); }
35% { transform: scaleX(0); }
75% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 열어서 보여주기만 ────
@media (prefers-reduced-motion: reduce) {
.frame__curtain, .frame.is-demo .frame__curtain { animation: none !important; transform: scaleX(0); }
}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);
}
.frame {
position: relative;
width: clamp(160px, 70vw, 280px);
aspect-ratio: 4/3;
border-radius: 12px;
overflow: hidden;
isolation: isolate;
}
.frame__image {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #ffd23f, #ff4d1f 60%, #17141a);
}
.frame__curtain {
position: absolute;
inset: 0;
background: #17141a;
transform: scaleX(1);
transform-origin: right;
}
@supports (animation-timeline: view()) {
.frame__curtain {
animation: curtain-open cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
}
@supports not (animation-timeline: view()) {
.frame__curtain {
transform: scaleX(0);
}
}
@keyframes curtain-open {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
.frame.is-demo .frame__curtain {
animation: curtain-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes curtain-loop {
0% {
transform: scaleX(1);
}
35% {
transform: scaleX(0);
}
75% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.frame__curtain, .frame.is-demo .frame__curtain {
animation: none !important;
transform: scaleX(0);
}
}import type { CSSProperties } from "react";
import styles from "./ImageCurtain.module.scss";
export type ImageCurtainProps = {
/** 이미지 URL. 비우면 데모용 그라데이션을 쓴다 */
src?: string;
alt?: string;
/** 커튼이 걷히는 시간 (기본 700ms) */
duration?: number;
/** 커튼 색 (기본 주황) */
curtain?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--curtain"?: string };
export default function ImageCurtain({
src,
alt = "",
duration = 700,
curtain = "#ff4d1f",
className = "",
}: ImageCurtainProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--curtain": curtain };
return (
<div className={`${styles.frame} ${className}`} style={vars}>
{src ? (
<img className={styles.image} src={src} alt={alt} />
) : (
<div className={styles.image} aria-hidden="true" />
)}
<div className={styles.curtain} />
</div>
);
}04 이미지 커튼 — 측정 누적면적 40.756% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 스크롤 애니메이션 9종 — AOS·GSAP 없이 JS 0줄로
이 데모가 속한 글
CSS 스크롤 애니메이션 9종 — AOS·GSAP 없이 JS 0줄로CSS 스크롤 애니메이션 아홉 개를 모았습니다. AOS·GSAP 없이 브라우저 내장 animation-timeline 하나로 스크롤 위치 자체를 애니메이션 시계로 씁니다. JS는 0줄이고, 격자에서는 아홉 개 효과가 이 글의 데모 전부 보기


