컴포넌트

Card

프로젝트, 에셋, 관련 정보를 하나의 영역에 표시합니다.

설치

pnpm dlx ply-ui add card button dialog icon

공통 설치 설정을 먼저 적용합니다.

사용법

프로젝트 목록, 에셋 목록, 정보 요약에 사용합니다. 제목과 설명만 필요한 경우에는 Header만 넣습니다. 이미지나 작업 버튼이 필요할 때 Media와 Footer를 추가합니다.

카드 너비는 배치하는 화면에서 정합니다. 아래 예제는 정보 카드 360px, 프로젝트 카드 320px, 에셋 카드 200px을 최대 너비로 사용하며 좁은 화면에서는 줄어듭니다.

구성

구성 요소역할
Card표면, 내부 여백, 세로·가로 배치를 정합니다.
CardMedia이미지 영역의 비율을 유지합니다.
CardImage이미지를 표시하고, 이미지가 없거나 불러오지 못하면 대체 내용을 표시합니다. Media 안에 넣습니다.
CardHeader제목, 설명, 부가 정보를 배치합니다.
CardTitle제목입니다. as로 페이지의 제목 계층에 맞춥니다.
CardDescription내용을 설명하는 문장입니다.
CardMeta파일 종류, 수정일, 항목 수 등 짧은 부가 정보입니다.
CardContent본문입니다. 가로 배치에서는 이미지 옆의 내용을 담습니다.
CardFooter작업 버튼을 배치합니다. 구분선은 divider로 추가합니다.
CardLink제목에 넣는 링크입니다. 기본적으로 카드 전체를 클릭할 수 있습니다.
CardAction제목에 넣는 버튼입니다. 상세 창 열기나 선택 동작에 사용합니다.

프로젝트 썸네일

가로 미리보기와 제목, 수정일을 표시합니다. variant="plain"은 바깥 테두리와 배경을 제거하고 이미지와 내용을 바로 이어 배치합니다. 기본 이미지 비율은 16:9입니다.

이 예제는 이미지나 제목을 누르면 실제 UI 문서로 이동합니다.

에셋 썸네일

ratio="square"로 정사각형 이미지 영역을 만듭니다. 모델이나 아이콘을 자르지 않고 보여주려면 Image에 fit="contain"을 지정합니다.

에셋 상세 보기

사진이나 제목을 누르면 큰 미리보기와 파일 정보가 열립니다. 페이지 이동에는 CardLink를, 창을 여는 동작에는 CardAction을 사용합니다. 창을 닫으면 누른 카드로 키보드 초점이 돌아갑니다.

에셋 선택해서 추가

사진이나 제목을 누르면 선택 테두리와 체크 표시가 생깁니다. 다시 누르면 선택을 해제합니다. '작업에 추가'를 누르면 아래 목록에 들어가고, 제거 버튼으로 되돌릴 수 있습니다.

Card의 selected는 선택 모양을, CardAction의 aria-pressed는 버튼의 선택 상태를 나타냅니다. 이 예제의 목록은 화면 안에서만 유지됩니다. 실제 앱에서는 추가 동작을 작업 데이터와 연결합니다.

가로 배치

최근 항목이나 좁은 목록에서 사용합니다. Card의 직접 자식으로 Media와 Content를 나란히 넣습니다. 이미지는 왼쪽 30%를 사용하며 최소 너비는 80px입니다. 긴 내용에 맞춰 이미지 영역도 늘어납니다.

작업 버튼

카드를 누르면 문서로 이동하고, 즐겨찾기 버튼은 이동 없이 상태만 바꿉니다. 실제 서비스에서는 이 상태를 저장소와 연결합니다.

카드 전체를 링크로 감싸지 않고 Title 안에 CardLink를 넣습니다. 별도의 버튼과 링크는 Footer에 두어 클릭 영역이 겹치지 않게 합니다. 제목만 링크로 사용하려면 stretch={false}로 설정합니다.

이미지 불러오기 실패

이미지가 없거나 로딩에 실패해도 Media의 비율을 유지합니다. 버튼을 누르면 잘못된 이미지 주소를 정상 이미지로 교체합니다.

이미지 자체에 의미가 있으면 alt로 설명합니다. 제목과 같은 정보를 반복하는 장식 이미지에는 alt=""를 지정합니다.

배경으로 구분

variant="filled"는 테두리 대신 배경색으로 영역을 구분합니다. 테두리가 필요한 경우 기본값인 outlined를 사용합니다.

작은 여백

density="compact"는 내부 여백을 16px에서 12px로 줄입니다. 제목과 짧은 부가 정보만 표시하거나 좁은 열에 배치할 때 사용합니다.

넓은 썸네일

ratio="wide"는 21:9 비율입니다. 가로로 긴 배너나 배경 이미지를 표시할 때 사용합니다. 기본 fit="cover"는 영역을 채우며 원본 비율이 다르면 일부가 잘립니다.

속성

Card

속성

타입

Media

속성

타입

Image

속성

타입

Title

속성

타입

속성

타입

속성

타입

Action

속성

타입

구현 코드

components/ui/card.tsx
card.tsx
"use client";
import { useState, type ComponentProps, type ReactNode } from "react";
import { cx } from "../../lib/cx";
import "./card.css";

export type CardProps = ComponentProps<"section"> & {
  /** 카드의 표면입니다. @defaultValue "outlined" */
  variant?: "outlined" | "filled" | "plain";
  /** 본문과 가장자리 사이의 간격입니다. @defaultValue "default" */
  density?: "default" | "compact";
  /** horizontal은 Media와 Content를 좌우로 배치합니다. @defaultValue "vertical" */
  layout?: "vertical" | "horizontal";
  /** 선택 상태를 표면에 표시합니다. 버튼에도 aria-pressed 등을 지정합니다. @defaultValue false */
  selected?: boolean;
};
export function Card({
  variant = "outlined",
  density = "default",
  layout = "vertical",
  selected = false,
  className,
  ...props
}: CardProps) {
  return (
    <section
      {...props}
      data-variant={variant}
      data-density={density}
      data-layout={layout}
      data-selected={selected || undefined}
      className={cx("rbx-card", className)}
    />
  );
}

export function CardHeader({ className, ...props }: ComponentProps<"header">) {
  return <header {...props} className={cx("rbx-card-header", className)} />;
}
export type CardTitleProps = ComponentProps<"h3"> & {
  /** 페이지의 제목 계층에 맞게 선택합니다. @defaultValue "h3" */
  as?: "h2" | "h3" | "h4";
};
export function CardTitle({
  as: Tag = "h3",
  className,
  ...props
}: CardTitleProps) {
  return <Tag {...props} className={cx("rbx-card-title", className)} />;
}
export function CardDescription({ className, ...props }: ComponentProps<"p">) {
  return <p {...props} className={cx("rbx-card-description", className)} />;
}
export function CardMeta({ className, ...props }: ComponentProps<"p">) {
  return <p {...props} className={cx("rbx-card-meta", className)} />;
}
export function CardContent({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-card-content", className)} />;
}
export type CardFooterProps = ComponentProps<"div"> & {
  /** 본문과 작업 영역 사이에 구분선을 표시합니다. @defaultValue false */
  divider?: boolean;
};
export function CardFooter({
  divider = false,
  className,
  ...props
}: CardFooterProps) {
  return (
    <div
      {...props}
      data-divider={divider || undefined}
      className={cx("rbx-card-footer", className)}
    />
  );
}
export type CardMediaProps = ComponentProps<"div"> & {
  /** 썸네일 비율입니다. 가로 배치에서는 정사각형을 기준으로 늘어납니다. @defaultValue "video" */
  ratio?: "video" | "square" | "wide";
};
export function CardMedia({
  ratio = "video",
  className,
  ...props
}: CardMediaProps) {
  return (
    <div
      {...props}
      data-ratio={ratio}
      className={cx("rbx-card-media", className)}
    />
  );
}
export type CardImageProps = Omit<ComponentProps<"img">, "alt"> & {
  /** 제목과 중복되는 장식 이미지에는 빈 문자열을 지정합니다. */
  alt: string;
  /** 이미지가 없거나 로딩에 실패했을 때 표시할 내용입니다. */
  fallback?: ReactNode;
  /** cover는 채우기, contain은 이미지 전체 표시입니다. @defaultValue "cover" */
  fit?: "cover" | "contain";
};
export function CardImage({
  src,
  srcSet,
  alt,
  fallback = "미리보기 없음",
  fit = "cover",
  className,
  onError,
  ...props
}: CardImageProps) {
  const [failed, setFailed] = useState<string>();
  // src 또는 srcSet이 바뀌면 이전 실패 결과를 재사용하지 않습니다.
  const identity = JSON.stringify([src, srcSet]);
  if ((!src && !srcSet) || failed === identity) {
    return (
      <div
        className={cx("rbx-card-image-fallback", className)}
        role={alt ? "img" : undefined}
        aria-label={alt || undefined}
        aria-hidden={alt ? undefined : true}
      >
        {fallback}
      </div>
    );
  }
  return (
    <img
      loading="lazy"
      decoding="async"
      {...props}
      src={src}
      srcSet={srcSet}
      alt={alt}
      data-fit={fit}
      className={cx("rbx-card-image", className)}
      onError={(event) => {
        setFailed(identity);
        onError?.(event);
      }}
    />
  );
}
export type CardLinkProps = ComponentProps<"a"> & {
  /** 제목 링크의 클릭 영역을 카드 전체로 넓힙니다. @defaultValue true */
  stretch?: boolean;
};
// Card 전체를 링크로 감싸지 않아 Footer에 별도 버튼을 둘 수 있습니다.
export function CardLink({
  stretch = true,
  className,
  ...props
}: CardLinkProps) {
  return (
    <a
      {...props}
      data-stretch={stretch || undefined}
      className={cx("rbx-card-link", className)}
    />
  );
}

export type CardActionProps = ComponentProps<"button"> & {
  /** 제목 버튼의 클릭 영역을 카드 전체로 넓힙니다. @defaultValue true */
  stretch?: boolean;
};
export function CardAction({
  stretch = true,
  type = "button",
  className,
  ...props
}: CardActionProps) {
  return (
    <button
      {...props}
      type={type}
      data-stretch={stretch || undefined}
      className={cx("rbx-card-action", className)}
    />
  );
}
components/ui/card.css
card.css
/* 표면과 내용 간격을 분리합니다. 이미지와 문서 예제 프레임에는 여백을 강제하지 않습니다. */
.rbx-card {
  --rbx-card-inset: 16px;
  position: relative;
  min-width: 0;
  border: 1px solid var(--rbx-color-stroke-default);
  border-radius: 8px;
  background: var(--rbx-color-surface-0);
  color: var(--rbx-color-content-emphasis);
  overflow: hidden;
}
.rbx-card[data-density="compact"] {
  --rbx-card-inset: 12px;
}
.rbx-card[data-variant="filled"] {
  border-color: transparent;
  background: var(--rbx-color-shift-200);
}
.rbx-card[data-variant="plain"] {
  border: 0;
  background: transparent;
  overflow: visible;
}
.rbx-card[data-selected] {
  box-shadow: inset 0 0 0 2px var(--rbx-color-system-emphasis);
}
.rbx-card[data-variant="plain"][data-selected] {
  box-shadow: none;
}
.rbx-card[data-variant="plain"][data-selected] > .rbx-card-media::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px solid var(--rbx-color-system-emphasis);
  border-radius: inherit;
  pointer-events: none;
}
.rbx-card-header,
.rbx-card-content,
.rbx-card-footer {
  padding: var(--rbx-card-inset);
  min-width: 0;
}
.rbx-card-header {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}
.rbx-card-content {
  display: grid;
  gap: 12px;
  font: var(--rbx-typography-body-medium-font);
}
.rbx-card-header + .rbx-card-content {
  padding-top: 0;
}
.rbx-card-title {
  margin: 0;
  font: var(--rbx-typography-title-large-font);
  overflow-wrap: anywhere;
}
.rbx-card-description {
  margin: 0;
  color: var(--rbx-color-content-default);
  font: var(--rbx-typography-body-medium-font);
  overflow-wrap: anywhere;
}
.rbx-card-meta {
  margin: 0;
  color: var(--rbx-color-content-default);
  font: var(--rbx-typography-body-small-font);
  overflow-wrap: anywhere;
}
.rbx-card-footer {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: 8px;
  padding-top: 0;
}
.rbx-card-footer[data-divider] {
  padding-top: 12px;
  border-top: 1px solid var(--rbx-color-stroke-default);
}
.rbx-card-media {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--rbx-color-shift-200);
}
.rbx-card-media[data-ratio="square"] {
  aspect-ratio: 1;
}
.rbx-card-media[data-ratio="wide"] {
  aspect-ratio: 21 / 9;
}
.rbx-card-image {
  display: block;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.rbx-card-image[data-fit="contain"] {
  object-fit: contain;
  padding: 12px;
}
.rbx-card-image-fallback {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 16px;
  color: var(--rbx-color-content-default);
  font: var(--rbx-typography-body-small-font);
}
.rbx-card[data-variant="plain"] > .rbx-card-media {
  border-radius: 8px;
}
.rbx-card[data-variant="plain"]
  > :is(.rbx-card-header, .rbx-card-content, .rbx-card-footer) {
  padding-inline: 0;
}
.rbx-card[data-variant="plain"] > .rbx-card-header {
  padding-top: 8px;
  padding-bottom: 0;
}
.rbx-card[data-variant="plain"] > .rbx-card-header + .rbx-card-footer {
  padding-top: 12px;
}
.rbx-card[data-layout="horizontal"] {
  display: grid;
  grid-template-columns: minmax(80px, 30%) minmax(0, 1fr);
  align-items: stretch;
}
.rbx-card[data-layout="horizontal"] > .rbx-card-media {
  aspect-ratio: 1;
  min-height: 100%;
}
.rbx-card[data-layout="horizontal"] > .rbx-card-content {
  align-content: center;
  gap: 4px;
}
.rbx-card[data-layout="horizontal"][data-variant="plain"] {
  gap: 12px;
}
.rbx-card-link,
.rbx-card-action {
  color: inherit;
  text-decoration: none;
  outline: none;
}
.rbx-card-action {
  padding: 0;
  border: 0;
  background: transparent;
  font: inherit;
  text-align: inherit;
  cursor: pointer;
}
:is(.rbx-card-link, .rbx-card-action)[data-stretch]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: 8px;
}
:is(.rbx-card-link, .rbx-card-action)[data-stretch]:focus-visible::after {
  outline: 2px solid var(--rbx-color-system-emphasis);
  outline-offset: -2px;
}
:is(.rbx-card-link, .rbx-card-action):not([data-stretch]):focus-visible {
  outline: 2px solid var(--rbx-color-system-emphasis);
  outline-offset: 3px;
}
.rbx-card:has(:is(.rbx-card-link, .rbx-card-action:enabled):hover)
  .rbx-card-title {
  text-decoration: underline;
  text-underline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .rbx-card-image {
    transition: transform var(--rbx-motion-duration-enter)
      var(--rbx-motion-ease-enter);
  }
  .rbx-card:has(:is(.rbx-card-link, .rbx-card-action:enabled):hover)
    .rbx-card-image[data-fit="cover"] {
    transform: scale(1.02);
  }
}

목차