컴포넌트
항목 카드
썸네일이 앞서는 라이브러리 항목 카드예요. 라이트·다크 썸네일과 호버 영상을 받아요.
- #arc
- #component
- #card
- #thumbnail
- #gallery
- #library
- #video
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/item-card.json파일
components/arc/item-card/item-card.module.csscomponents/arc/item-card/item-card.tsx
함께 오는 항목
item-card.module.cssitem-card.module.cssitem-card.module.csscss
.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.tsxitem-card.tsxitem-card.tsxtsx
"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;