# 항목 카드

썸네일이 앞서는 라이브러리 항목 카드예요. 라이트·다크 썸네일과 호버 영상을 받아요.

- Type: component
- Theme: base
- Tags: arc, component, card, thumbnail, gallery, library, video
- English title: Item card
- URL: https://dsui.vercel.app/items/item-card

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| title | text | 제목 | 링크형 버튼 |
| summary | text | 요약 | 버튼처럼 보이지만 요소는 링크예요. |

## Source

### item-card.module.css

```css
.wrap { position: relative; display: flex; min-width: 0; }
.card { display: flex; min-width: 0; flex: 1 1 auto; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); text-decoration: none; -webkit-tap-highlight-color: transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
/* A fixed ratio so a grid of cards lines up whatever the pictures are. */
.thumb { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; background: var(--surface-sunken); }
.image, .video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.video { opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }
.video[data-playing="true"] { opacity: 1; }
.placeholder { position: absolute; inset: 0; display: grid; place-items: center; background: var(--highlight-subtle); color: var(--foreground); font-size: var(--text-2xl); font-weight: 600; }
/* One still per theme; only the one that matches is painted. */
.darkOnly { display: none; }
:global(:root[data-theme="dark"]) .lightOnly, :global(:root.dark) .lightOnly { display: none; }
:global(:root[data-theme="dark"]) .darkOnly, :global(:root.dark) .darkOnly { display: block; }
@media (prefers-color-scheme: dark) {
  :global(:root:not([data-theme="light"])) .lightOnly { display: none; }
  :global(:root:not([data-theme="light"])) .darkOnly { display: block; }
}
.body { display: flex; min-width: 0; flex-direction: column; gap: var(--space-2); padding: var(--space-4) var(--space-4) var(--space-5); }
.badges { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.title { color: var(--foreground); font-size: var(--text-base); font-weight: 600; line-height: 1.4; }
.summary { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.5; word-break: keep-all; }
/* Outside the link: a number is not a destination, and inside it would be read as part of the item's name. */
.count { position: absolute; inset-block-start: var(--space-3); inset-inline-end: var(--space-3); display: inline-flex; align-items: center; gap: var(--space-1); border-radius: var(--radius-pill); padding: var(--space-1) var(--space-2); background: var(--surface); color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--label-weight); box-shadow: var(--shadow-resting); pointer-events: none; }
.countIcon { display: inline-flex; flex: none; }
@media (hover: hover) and (pointer: fine) {
  .card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-raised); transform: translateY(-2px); }
}
.card:active { box-shadow: var(--shadow-resting); transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
  .card, .video { transition: none; }
  .card:hover, .card:active { transform: none; }
}

```

### item-card.tsx

```tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ArcLink } from "../lib/arc-provider";
import { playUiSound } from "../sound/play";
import styles from "./item-card.module.css";
import { prefersReducedMotion } from "../lib/use-reduced-motion";

export interface ItemCardProps {
  href: string;
  title: string;
  summary?: string;
  /** The still, one per theme. Give both when the thumbnail is a screenshot of the item. */
  thumbnail?: { light: string; dark?: string };
  /** Plays on hover and on keyboard focus, and never when the reader asked for less motion. */
  video?: string;
  /** Badges above the title, usually the item's type. */
  badges?: ReactNode;
  /** A count shown in the corner, e.g. likes. `label` names what is counted, for the reader and for assistive tech. */
  count?: { value: number; label: string; icon?: ReactNode };
  /** Drawn in place of the thumbnail when there is none, or when the image fails. */
  placeholder?: ReactNode;
  /** The language of the title and summary, when they differ from the page. */
  lang?: string;
  className?: string;
}

/**
 * A library item: a thumbnail, what kind of thing it is, its name and one line about it. The thumbnail is the point,
 * so it leads; everything under it is there to tell two similar pictures apart.
 *
 * The count sits outside the link, because a number is not a destination and would otherwise be read as part of the
 * item's name.
 */
export function ItemCard({ href, title, summary, thumbnail, video, badges, count, placeholder, lang, className }: ItemCardProps) {
  const id = useId();
  const [failed, setFailed] = useState(false);
  const [playing, setPlaying] = useState(false);
  const videoRef = useRef<HTMLVideoElement>(null);
  const images = useRef<(HTMLImageElement | null)[]>([]);

  // An image that failed before hydration never fires React's onError, so they are checked once on mount.
  useEffect(() => {
    if (images.current.some(img => img && img.complete && img.naturalWidth === 0)) setFailed(true);
  }, []);

  const start = useCallback(() => {
    if (!video || prefersReducedMotion()) return;
    setPlaying(true);
    videoRef.current?.play().catch(() => setPlaying(false));
  }, [video]);

  const stop = useCallback(() => {
    setPlaying(false);
    const el = videoRef.current;
    if (el) { el.pause(); el.currentTime = 0; }
  }, []);

  const showPlaceholder = failed || !thumbnail;

  return (
    <div className={[styles.wrap, className].filter(Boolean).join(" ")}>
      <ArcLink
        href={href}
        aria-labelledby={`${id}-title`}
        aria-describedby={summary ? `${id}-summary` : undefined}
        className={styles.card}
        onMouseEnter={start}
        onMouseLeave={stop}
        onFocus={start}
        onBlur={stop}
        onClick={() => playUiSound("tick")}
      >
        <span className={styles.thumb}>
          {showPlaceholder ? (
            <span aria-hidden="true" className={styles.placeholder}>{placeholder ?? title.slice(0, 1)}</span>
          ) : (
            <>
              {/* eslint-disable-next-line @next/next/no-img-element -- generated thumbnails that may be missing; next/image cannot report that. */}
              <img ref={el => { images.current[0] = el; }} src={thumbnail.light} alt="" loading="lazy" onError={() => setFailed(true)} className={`${styles.image} ${styles.lightOnly}`} />
              {thumbnail.dark ? (
                /* eslint-disable-next-line @next/next/no-img-element -- as above */
                <img ref={el => { images.current[1] = el; }} src={thumbnail.dark} alt="" loading="lazy" onError={() => setFailed(true)} className={`${styles.image} ${styles.darkOnly}`} />
              ) : null}
            </>
          )}
          {video ? (
            <video ref={videoRef} muted loop playsInline preload="none" src={video} aria-hidden="true" className={styles.video} data-playing={playing ? "true" : undefined} />
          ) : null}
        </span>
        <span className={styles.body}>
          {badges ? <span className={styles.badges}>{badges}</span> : null}
          <span id={`${id}-title`} lang={lang} className={styles.title}>{title}</span>
          {summary ? <span id={`${id}-summary`} lang={lang} className={styles.summary}>{summary}</span> : null}
        </span>
      </ArcLink>
      {count && count.value > 0 ? (
        <span className={styles.count}>
          {count.icon ? <span className={styles.countIcon} aria-hidden="true">{count.icon}</span> : null}
          <span>{`${count.label} ${count.value}`}</span>
        </span>
      ) : null}
    </div>
  );
}

ItemCard.displayName = "ItemCard";

export default ItemCard;

```
