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

items
필수

readonly T[]

지정 안 함

renderItem
필수

(item: T, context: CardStackRenderContext) => ReactNode

지정 안 함

activeIndex

number

지정 안 함

activeRotation

number

0

compactStackHeight

string | number

지정 안 함

compactStackSize

string | number

지정 안 함

contentClassName

string

지정 안 함

contentSx

SystemStyleObject | SystemStyleObject[]

지정 안 함

defaultActiveIndex

number

0

fadeBackCards

boolean

true

getItemAriaLabel

(item: T, index: number) => string

지정 안 함

getItemAspectRatio

(item: T, index: number) => number

지정 안 함

getItemKey

(item: T, index: number) => Key

지정 안 함

getItemRotation

(item: T, index: number) => number

지정 안 함

itemClassName

string

지정 안 함

itemOverflow

"hidden" | "visible"

hidden

itemSurface

"default" | "plain"

default

itemSx

SystemStyleObject | SystemStyleObject[]

지정 안 함

maxRotate

number

5

minScale

number

0.5

minSwipeDistanceRatio

number

0.5

minSwipeSpeed

number

50

onActiveIndexChange

(index: number) => void

지정 안 함

onSwipe

(event: CardStackSwipeEvent<T>) => void

지정 안 함

pressScale

number

0.98

stackHeight

string | number

지정 안 함

stackOffset

number

0

stackSize

string | number

지정 안 함

FlipCard

Prop

Type

Default

aria-label
필수

string

지정 안 함

back
필수

ReactNode

지정 안 함

front
필수

ReactNode

지정 안 함

cardDepthRatio

number

0.02

cornerGranularity

number

1

defaultFlipped

boolean

false

disabled

boolean

false

flipDuration

number

0.3

flipOrigin

string

center

flipped

boolean

지정 안 함

liftDistanceRatio

number

7 / 6

onFlip

(event: FlipCardFlipEvent) => void

지정 안 함

perspective

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;