반응형
리액트 UI 작업
썸네일 이미지 자동 뿌려짐
메뉴를 클릭하면 하단 콘텐츠가 좌→우로 펼쳐지고, 우측의 슬라이더로 속도(0.2–3.0s) 를 조절할 수 있고,
카드 개수와 크기를 UI에서 직접 조절할 수 있도록
구현한 코드
카드 개수: 1~20개 설정 가능
너비·높이(px): 각각 50~400 범위에서 변경
import React, { useState } from "react"; import { motion } from "framer-motion"; import { ChevronDown, Play, Zap } from "lucide-react";
const MENUS = [ "mn1", "mn2", "콘텐츠", "mn4", "mn5", ];
const Card = ({ index, width, height }: { index: number; width: number; height: number }) => (
<div
className="shrink-0 rounded-2xl border border-gray-300 bg-white shadow-sm grid place-items-center text-gray-700"
style={{ width, height }}
>
<span className="text-sm">카드 {index + 1}</span>
</div>
);export default function LeftToRightRevealDemo() { const [active, setActive] = useState<string>("콘텐츠"); const [duration, setDuration] = useState<number>(1.2); const [runId, setRunId] = useState(0); const [cardCount, setCardCount] = useState<number>(6); const [cardWidth, setCardWidth] = useState<number>(160); const [cardHeight, setCardHeight] = useState<number>(112);
const restart = () => setRunId((n) => n + 1);
const onMenuClick = (m: string) => { setActive(m); restart(); };
const clipStart = "inset(0 100% 0 0)"; const clipEnd = "inset(0 0% 0 0)"; const ease = [0.22, 1, 0.36, 1] as const;
return ( <div className="min-h-[520px] w-full bg-gray-50 text-gray-900 p-6"> <div className="mx-auto max-w-5xl"> <h1 className="text-xl font-semibold mb-4">좌 → 우 펼쳐지는 Reveal UI</h1>
<div className="flex flex-wrap items-center gap-4 rounded-2xl bg-white p-4 shadow-sm border">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4" />
<span className="text-sm text-gray-600">속도(초)</span>
<input
type="range"
min={0.2}
max={3}
step={0.1}
value={duration}
onChange={(e) => setDuration(parseFloat(e.target.value))}
className="w-40"
/>
<span className="text-sm tabular-nums w-10 text-right">{duration.toFixed(1)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">카드 개수</span>
<input
type="number"
min={1}
max={20}
value={cardCount}
onChange={(e) => setCardCount(parseInt(e.target.value))}
className="w-16 border rounded px-1 py-0.5 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">카드 너비(px)</span>
<input
type="number"
min={50}
max={400}
value={cardWidth}
onChange={(e) => setCardWidth(parseInt(e.target.value))}
className="w-20 border rounded px-1 py-0.5 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">카드 높이(px)</span>
<input
type="number"
min={50}
max={400}
value={cardHeight}
onChange={(e) => setCardHeight(parseInt(e.target.value))}
className="w-20 border rounded px-1 py-0.5 text-sm"
/>
</div>
<button
onClick={restart}
className="inline-flex items-center gap-2 rounded-xl bg-black/90 px-3 py-2 text-white text-sm shadow hover:bg-black"
>
<Play className="w-4 h-4" /> 다시 재생
</button>
</div>
<div className="mt-6 grid grid-cols-5 gap-3">
{MENUS.map((m) => (
<button
key={m}
onClick={() => onMenuClick(m)}
className={
"rounded-2xl border bg-white px-4 py-3 text-center text-sm shadow-sm transition " +
(active === m
? "border-black/80 font-semibold"
: "border-gray-300 hover:border-gray-400")
}
>
{m}
</button>
))}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<ChevronDown className="w-3 h-3" />
<span>메뉴를 클릭하면 하단 콘텐츠가 좌측에서 우측으로 펼쳐집니다.</span>
</div>
<div className="relative mt-12">
<div className="h-px w-full bg-gray-300" />
<motion.div
key={`${active}-${runId}-${duration}-${cardCount}-${cardWidth}-${cardHeight}`}
initial={{ clipPath: clipStart }}
animate={{ clipPath: clipEnd }}
transition={{ duration, ease }}
className="absolute inset-0 overflow-visible"
>
<div className="-top-16 absolute left-0 right-0 flex items-center gap-8">
{[...Array(cardCount)].map((_, i) => (
<Card key={i} index={i} width={cardWidth} height={cardHeight} />
))}
</div>
<motion.div
initial={{ width: 0 }}
animate={{ width: "100%" }}
transition={{ duration, ease }}
className="h-[3px] bg-gray-800"
/>
</motion.div>
</div>
</div>
</div>
); }
App.jsx
import { useState } from "react";
const demoImages = Array.from(
{ length: 12 },
(_, i) => `https://picsum.photos/seed/${i + 1}/400/400`
);
export default function App() {
// 메뉴 상태
const [active, setActive] = useState(false);
// 커스터마이즈 옵션
const CARD_SIZE = 140; // 카드 한 변(px)
const STAGGER_MS = 120; // 순차 지연 간격(ms)
const ANIM_DURATION_MS = 420; // 카드 1장 애니메이션 시간(ms)
const handleMenuClick = () => {
// 다시 클릭 시에도 처음부터 재생하고 싶다면 아래 두 줄 사용
setActive(false);
requestAnimationFrame(() => setActive(true));
// 단순 토글이면: setActive(v => !v)
};
return (
<div
style={{
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
}}
>
{/* 상단 메뉴 */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 1,
display: "flex",
gap: 8,
padding: "12px 16px",
borderBottom: "1px solid #eee",
background: "#fff",
}}
>
<button
onClick={handleMenuClick}
style={{
padding: "10px 14px",
borderRadius: 10,
border: "1px solid #ddd",
background: "#111",
color: "#fff",
cursor: "pointer",
}}
>
콘텐츠 보기
</button>
{/* (선택) 속도 조절 슬라이더 예시 */}
{/* <input type="range" min="60" max="300" value={STAGGER_MS} onChange={()=>{}} /> */}
</div>
{/* 하단 카드 영역: 가로 스크롤 */}
<div
style={{
padding: 16,
overflowX: "auto",
whiteSpace: "nowrap",
}}
>
<div style={{ display: "inline-flex", gap: 16 }}>
{demoImages.map((src, idx) => (
<Card
key={src}
src={src}
size={CARD_SIZE}
// index에 따라 각 카드의 애니메이션 시작을 지연
delayMs={idx * STAGGER_MS}
durationMs={ANIM_DURATION_MS}
play={active}
/>
))}
</div>
</div>
</div>
);
}
function Card({
src,
size = 140,
delayMs = 0,
durationMs = 420,
play = false,
}) {
return (
<div
className={`card ${play ? "card-in" : ""}`}
style={{
width: size,
height: size,
borderRadius: 14,
border: "1px solid #e5e7eb",
overflow: "hidden",
background: "#f8fafc",
// 애니메이션 제어
animationDelay: `${delayMs}ms`,
animationDuration: `${durationMs}ms`,
}}
>
<img
src={src}
alt=""
draggable="false"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
);
}
app.css
.App {
font-family: sans-serif;
text-align: center;
}
/* 기본 */
.card {
opacity: 0;
transform: translateX(-140px) translateY(6px) scale(0.92) rotateZ(-1deg);
filter: blur(6px);
will-change: transform, opacity, filter;
transform-origin: left center;
}
/* 메뉴 클릭 후 순차 등장: '날아오는' 모션 */
.card.card-in {
animation-name: flyInLTR;
animation-fill-mode: both;
/* duration, delay는 인라인으로 카드별 지정됨 */
animation-timing-function: cubic-bezier(
0.23,
0.89,
0.43,
1.22
); /* 살짝 튀듯이 */
}
@keyframes flyInLTR {
0% {
opacity: 0;
transform: translateX(-140px) translateY(6px) scale(0.92) rotateZ(-1.5deg);
filter: blur(6px);
}
60% {
/* 살짝 앞으로 치고 나감(오버슈트) */
opacity: 1;
transform: translateX(8px) translateY(0) scale(1.02) rotateZ(0deg);
filter: blur(1.5px);
}
100% {
opacity: 1;
transform: translateX(0) translateY(0) scale(1) rotateZ(0deg);
filter: blur(0);
}
}
/* 스크롤바(선택) */
::-webkit-scrollbar {
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 8px;
}
/* 모션 줄이기 설정을 존중 */
@media (prefers-reduced-motion: reduce) {
.card {
transform: none !important;
filter: none !important;
}
.card.card-in {
animation: fadeIn 300ms both;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
결과화면
반응형
'퍼블리싱 로그 > JS' 카테고리의 다른 글
js 슬라이드, 페이지네이션 (0) | 2024.04.16 |
---|---|
하드코딩 chart(js) / 그라데이션(css) (0) | 2024.03.14 |
length > 0 해당 요소가 있을경우 (0) | 2024.03.05 |
fade 효과 - setInterval( ) - 2가지 이미지 깜빡이며 보이기 (0) | 2024.03.04 |
이미지 사이즈에 맞게 팝업 띄우기 (0) | 2024.03.04 |