CardStack
Data Display
카드를 겹쳐 쌓고 좌우로 넘기는 스택 컨테이너.
Usage
이미지·텍스트·플래시카드처럼 여러 항목을 한 장씩 순환해 보여줄 때
import
import
import { CardStack } from '@mildang/design-system/unofficial/CardStack';예제를 복사해 쓸 때 필요한 준비
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
API Reference
CardStack Props
Prop
Type
Default
readonly T[]
지정 안 함
(item: T, context: CardStackRenderContext) => ReactNode
지정 안 함
number
지정 안 함
number
0
string | number
지정 안 함
string | number
지정 안 함
string
지정 안 함
SystemStyleObject | SystemStyleObject[]
지정 안 함
number
0
boolean
true
(item: T, index: number) => string
지정 안 함
(item: T, index: number) => number
지정 안 함
(item: T, index: number) => Key
지정 안 함
(item: T, index: number) => number
지정 안 함
string
지정 안 함
"hidden" | "visible"
hidden
"default" | "plain"
default
SystemStyleObject | SystemStyleObject[]
지정 안 함
number
5
number
0.5
number
0.5
number
50
(index: number) => void
지정 안 함
(event: CardStackSwipeEvent<T>) => void
지정 안 함
number
0.98
string | number
지정 안 함
number
0
string | number
지정 안 함
FlipCard
Prop
Type
Default
string
지정 안 함
ReactNode
지정 안 함
ReactNode
지정 안 함
number
0.02
number
1
boolean
false
boolean
false
number
0.3
string
center
boolean
지정 안 함
number
7 / 6
(event: FlipCardFlipEvent) => void
지정 안 함
string | number
100em
예제
기본 사용
사진 세 장을 좌우로 스와이프하며 순환하는 기본형입니다.
코드
import { CardStack } from '@mildang/design-system/unofficial/CardStack';
import { Image } from '@mildang/design-system/Image';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
interface PhotoCard {
src: string;
alt: string;
ratio: number;
rotation: number;
}
const animalPhotos: PhotoCard[] = [
{
src: '/photos/animals/golden-retriever.jpg',
alt: '파란 배경 앞에서 정면을 바라보는 골든리트리버',
ratio: 1,
rotation: 0,
},
{
src: '/photos/animals/giraffe.jpg',
alt: '푸른 하늘을 배경으로 서 있는 기린',
ratio: 1,
rotation: -5,
},
{
src: '/photos/animals/tiger.jpg',
alt: '풀숲에서 얼굴을 가까이 내민 호랑이',
ratio: 1,
rotation: 5,
},
];
const imageStyle = css({
width: '[100%]',
height: '[100%]',
objectFit: 'cover',
userSelect: 'none',
touchAction: 'none',
});
function AnimalPhotoStack() {
return (
<>
<CardStack
aria-label="Photo card stack"
items={animalPhotos}
getItemKey={(photo) => photo.src}
getItemAspectRatio={(photo) => photo.ratio}
getItemRotation={(photo) => photo.rotation}
renderItem={(photo) => (
<Image
src={photo.src}
fill
sizes="(max-width: 600px) 200px, 400px"
alt={photo.alt}
className={imageStyle}
onPointerDown={(event) => event.preventDefault()}
/>
)}
minScale={1}
stackOffset={24}
fadeBackCards={false}
/>
<Text as="p" variant="contents-sm" color="neutral.text.low" textAlign="center" px="8" py="20">
Swipe the top photo left or right.
<br />
Swiped photos move to the back of the stack.
</Text>
</>
);
}
const CardStackDefaultExample = () => <AnimalPhotoStack />;
export default CardStackDefaultExample;
혼합 콘텐츠
이미지뿐 아니라 텍스트·버튼 같은 임의의 React 콘텐츠도 카드로 렌더합니다.
코드
import { CardStack } from '@mildang/design-system/unofficial/CardStack';
import { Image } from '@mildang/design-system/Image';
import { Text } from '@mildang/design-system/Text';
import { useState } from 'react';
import { Button } from '@mildang/design-system/Button';
import { css, cva } from '@mildang/styled-system/css';
const imageStyle = css({
width: '[100%]',
height: '[100%]',
objectFit: 'cover',
userSelect: 'none',
touchAction: 'none',
});
const contentStyle = cva({
base: {
width: '[100%]',
height: '[100%]',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '16',
padding: '24',
},
variants: {
tone: {
text: {
backgroundColor: 'info.fill.base',
},
action: {
backgroundColor: 'brand.fill.base',
},
},
},
});
interface DemoCard {
id: string;
type: 'image' | 'text' | 'action';
ratio: number;
rotation: number;
}
const demoCards: DemoCard[] = [
{ id: 'image', type: 'image', ratio: 1, rotation: 0 },
{ id: 'text', type: 'text', ratio: 1, rotation: -5 },
{ id: 'action', type: 'action', ratio: 1, rotation: 5 },
];
function InteractiveCard() {
const [count, setCount] = useState(0);
return (
<div className={contentStyle({ tone: 'action' })}>
<Text as="strong" variant="title-2xl" color="inverse.text.base">
버튼도 카드 안에서 동작합니다
</Text>
<Button variant="primary" onClick={() => setCount((current) => current + 1)}>
클릭 {count}회
</Button>
</div>
);
}
function renderDemoCard(card: DemoCard) {
switch (card.type) {
case 'image':
return (
<Image
src="/photos/animals/golden-retriever.jpg"
fill
sizes="(max-width: 600px) 200px, 400px"
alt="파란 배경 앞에서 정면을 바라보는 골든리트리버"
className={imageStyle}
onPointerDown={(event) => event.preventDefault()}
/>
);
case 'text':
return (
<article className={contentStyle({ tone: 'text' })}>
<Text as="h3" variant="title-2xl" color="inverse.text.base">
자유로운 콘텐츠
</Text>
<Text color="inverse.text.base" textAlign="center">
이미지, 텍스트, 영상, 폼 등 어떤 React 요소든 넣을 수 있습니다.
</Text>
</article>
);
case 'action':
return <InteractiveCard />;
}
}
function MixedContentCardStack() {
return (
<CardStack
aria-label="콘텐츠 카드 스택"
items={demoCards}
getItemKey={(card) => card.id}
getItemAspectRatio={(card) => card.ratio}
getItemRotation={(card) => card.rotation}
getItemAriaLabel={(card, index) => `${index + 1}번째 ${card.type} 카드`}
renderItem={(card) => renderDemoCard(card)}
minScale={1}
stackOffset={24}
fadeBackCards={false}
/>
);
}
const CardStackMixedContentExample = () => <MixedContentCardStack />;
export default CardStackMixedContentExample;
플래시카드 조합
CardStack 의 좌우 순환과 FlipCard 의 뒤집기를 한 카드 안에서 함께 사용합니다.
코드
import { CardStack } from '@mildang/design-system/unofficial/CardStack';
import { FlipCard } from '@mildang/design-system/unofficial/CardStack';
import { Image } from '@mildang/design-system/Image';
import { Text } from '@mildang/design-system/Text';
import { css, cva } from '@mildang/styled-system/css';
const imageStyle = css({
width: '[100%]',
height: '[100%]',
objectFit: 'cover',
userSelect: 'none',
touchAction: 'none',
});
const flashCardFaceStyle = cva({
base: {
width: '[100%]',
height: '[100%]',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '12',
padding: '24',
textAlign: 'center',
},
variants: {
tone: {
answer: { backgroundColor: 'neutral.surface.low' },
strawberry: { backgroundColor: 'critical.surface.base' },
orange: { backgroundColor: 'warning.surface.base' },
blueberry: { backgroundColor: 'info.surface.base' },
},
},
});
const animalFrontStyle = css({
width: '[100%]',
height: '[100%]',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'primary.bg.base',
});
const animalPhotoStyle = css({
position: 'relative',
width: '[100%]',
flex: '1',
overflow: 'hidden',
});
const animalLabelStyle = css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4',
padding: '20',
});
const photoCreditStyle = css({
color: 'neutral.text.low',
textDecoration: 'underline',
textUnderlineOffset: '2',
_focusVisible: {
outerFocus: true,
},
});
interface VocabularyCard {
id: string;
word: string;
meaning: string;
example: string;
rotation: number;
imageSrc: string;
imageAlt: string;
photographer: string;
photoUrl: string;
}
/** Unsplash License 무료 사진. 상세 출처는 public/photos/animals/README.md에 기록한다. */
const vocabularyCards: VocabularyCard[] = [
{
id: 'golden-retriever',
word: 'dog',
meaning: '개',
example: 'The dog is looking at the camera.',
rotation: 0,
imageSrc: '/photos/animals/golden-retriever.jpg',
imageAlt: '파란 배경 앞에서 정면을 바라보는 골든리트리버',
photographer: 'Victor G',
photoUrl: 'https://unsplash.com/photos/x5oPmHmY3kQ',
},
{
id: 'giraffe',
word: 'giraffe',
meaning: '기린',
example: 'The giraffe has a long neck.',
rotation: -5,
imageSrc: '/photos/animals/giraffe.jpg',
imageAlt: '푸른 하늘을 배경으로 서 있는 기린',
photographer: 'Andreas Rasmussen',
photoUrl: 'https://unsplash.com/photos/NNe6epzHGm8',
},
{
id: 'tiger',
word: 'tiger',
meaning: '호랑이',
example: 'The tiger has black stripes.',
rotation: 5,
imageSrc: '/photos/animals/tiger.jpg',
imageAlt: '풀숲에서 얼굴을 가까이 내민 호랑이',
photographer: 'Jakob Owens',
photoUrl: 'https://unsplash.com/photos/YAk9qnU-LxI',
},
];
function FlashCardStack() {
return (
<>
<CardStack
aria-label="Vocabulary flash card stack"
items={vocabularyCards}
getItemKey={(card) => card.id}
getItemAspectRatio={() => 5 / 7}
getItemRotation={(card) => card.rotation}
getItemAriaLabel={(card, index) => `${index + 1}번째 ${card.word} 카드`}
renderItem={(card) => (
<FlipCard
aria-label={`${card.word} 플래시카드. 탭하거나 Enter 키로 뒤집기`}
front={
<article className={animalFrontStyle}>
<div className={animalPhotoStyle}>
<Image
src={card.imageSrc}
fill
sizes="(max-width: 600px) 200px, 400px"
alt={card.imageAlt}
className={imageStyle}
draggable={false}
/>
</div>
<div className={animalLabelStyle}>
<Text variant="contents-sm" color="neutral.text.low">
WHAT ANIMAL?
</Text>
</div>
</article>
}
back={
<article className={flashCardFaceStyle({ tone: 'answer' })}>
<Text variant="contents-sm" color="neutral.text.low">
ANIMAL
</Text>
<Text as="strong" variant="title-2xl" color="neutral.text.base">
{card.word}
</Text>
<Text variant="title-md-medium" color="neutral.text.base">
{card.meaning}
</Text>
<Text color="neutral.text.low">{card.example}</Text>
<a
href={card.photoUrl}
target="_blank"
rel="noreferrer"
className={photoCreditStyle}
data-flip-card-no-drag
>
Photo: {card.photographer} / Unsplash
</a>
</article>
}
/>
)}
minScale={1}
stackOffset={24}
itemOverflow="visible"
itemSurface="plain"
fadeBackCards={false}
pressScale={1}
/>
<Text as="p" variant="contents-sm" color="neutral.text.low" textAlign="center" px="8" py="20">
Tap the active card to reveal the answer.
<br />
Swipe left or right to move it to the back.
</Text>
</>
);
}
const CardStackFlashCardsExample = () => <FlashCardStack />;
export default CardStackFlashCardsExample;
대형 플래시카드
640×400 가로형 카드에 서로 다른 뒷면 배경을 적용한 예시입니다.
코드
import { CardStack } from '@mildang/design-system/unofficial/CardStack';
import { FlipCard } from '@mildang/design-system/unofficial/CardStack';
import { Image } from '@mildang/design-system/Image';
import { Text } from '@mildang/design-system/Text';
import { css, cva } from '@mildang/styled-system/css';
const imageStyle = css({
width: '[100%]',
height: '[100%]',
objectFit: 'cover',
userSelect: 'none',
touchAction: 'none',
});
const flashCardFaceStyle = cva({
base: {
width: '[100%]',
height: '[100%]',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '12',
padding: '24',
textAlign: 'center',
},
variants: {
tone: {
answer: { backgroundColor: 'neutral.surface.low' },
strawberry: { backgroundColor: 'critical.surface.base' },
orange: { backgroundColor: 'warning.surface.base' },
blueberry: { backgroundColor: 'info.surface.base' },
},
},
});
const fruitFrontStyle = css({
position: 'relative',
width: '[100%]',
height: '[100%]',
overflow: 'hidden',
});
const storyViewportStyle = css({
width: '[100vw]',
marginInline: '[calc(50% - 50vw)]',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflowX: 'clip',
overflowY: 'visible',
});
const photoCreditStyle = css({
color: 'neutral.text.low',
textDecoration: 'underline',
textUnderlineOffset: '2',
_focusVisible: {
outerFocus: true,
},
});
type FruitTone = 'strawberry' | 'orange' | 'blueberry';
interface FruitCard {
id: string;
word: string;
meaning: string;
example: string;
rotation: number;
imageSrc: string;
imageAlt: string;
photographer: string;
photoUrl: string;
tone: FruitTone;
}
const fruitCards: FruitCard[] = [
{
id: 'strawberry',
word: 'strawberry',
meaning: '딸기',
example: 'Strawberries grow close to the ground.',
rotation: 0,
imageSrc: '/photos/fruits/strawberry.jpg',
imageAlt: '초록 잎 사이에서 익어가는 빨간 딸기',
photographer: 'H&CO',
photoUrl: 'https://unsplash.com/photos/rfK7qmyPOEg',
tone: 'strawberry',
},
{
id: 'orange',
word: 'orange',
meaning: '오렌지',
example: 'Orange slices are rich in vitamin C.',
rotation: -4,
imageSrc: '/photos/fruits/orange.jpg',
imageAlt: '나무 도마 위에 펼쳐진 오렌지 조각',
photographer: 'Erin',
photoUrl: 'https://unsplash.com/photos/3drdqQ6bguU',
tone: 'orange',
},
{
id: 'blueberry',
word: 'blueberry',
meaning: '블루베리',
example: 'Blueberries are small and sweet.',
rotation: 4,
imageSrc: '/photos/fruits/blueberry.jpg',
imageAlt: '흰 그릇에 담긴 신선한 블루베리',
photographer: 'Yulia Khlebnikova',
photoUrl: 'https://unsplash.com/photos/8l_Ux2WYpqM',
tone: 'blueberry',
},
];
function FruitFlashCardStack() {
return (
<div className={storyViewportStyle}>
<CardStack
aria-label="Fruit flash card stack"
items={fruitCards}
getItemKey={(card) => card.id}
getItemAspectRatio={() => 8 / 5}
getItemRotation={(card) => card.rotation}
getItemAriaLabel={(card, index) => `${index + 1}번째 ${card.word} 과일 카드`}
renderItem={(card) => (
<FlipCard
aria-label={`${card.word} 과일 플래시카드. 탭하거나 Enter 키로 뒤집기`}
liftDistanceRatio={0.5}
front={
<article className={fruitFrontStyle}>
<Image
src={card.imageSrc}
fill
sizes="(max-width: 600px) 320px, 640px"
alt={card.imageAlt}
className={imageStyle}
draggable={false}
/>
</article>
}
back={
<article className={flashCardFaceStyle({ tone: card.tone })}>
<Text variant="contents-sm" color="neutral.text.low">
FRUIT
</Text>
<Text as="strong" variant="title-2xl" color="neutral.text.base">
{card.word}
</Text>
<Text variant="title-md-medium" color="neutral.text.base">
{card.meaning}
</Text>
<Text color="neutral.text.base">{card.example}</Text>
<a
href={card.photoUrl}
target="_blank"
rel="noreferrer"
className={photoCreditStyle}
data-flip-card-no-drag
>
Photo: {card.photographer} / Unsplash
</a>
</article>
}
/>
)}
stackSize={640}
stackHeight={400}
compactStackSize={320}
compactStackHeight={200}
minScale={1}
stackOffset={20}
itemOverflow="visible"
itemSurface="plain"
fadeBackCards={false}
pressScale={1}
/>
<Text as="p" variant="contents-sm" color="neutral.text.low" textAlign="center" px="8" py="20">
Tap the active card to reveal the fruit.
<br />
Swipe left or right to move it to the back.
</Text>
</div>
);
}
const CardStackLargeFlashCardsExample = () => <FruitFlashCardStack />;
export default CardStackLargeFlashCardsExample;