UI / Layouts

CardDetail

Composable card detail experience with independently optional facts, collection state, and market history.

M Venusaur EX Pokemon card

M Venusaur EX

Market Price

Source
+Source
Condition
LP
Variant
Holofoil

Your collection

  • Lightly Played trading card

    Lightly Played

    Jun 18, 2026$24.00
    $27.97

Details

Type
HP
230
Stage
Mega
Card text
  1. When 1 of your Pokemon becomes a Mega Evolution Pokemon, your turn ends.
  2. Your opponent's Active Pokemon is now Paralyzed and Poisoned.
Attack
Crisis Vine - 120. Your opponent's Active Pokemon is now Paralyzed and Poisoned.
Weakness
"use client"; import { useState } from "react";import { CardDetail, type CardDetailFact } from "@cardspark/ui/layouts"; const card = {  id: "evo-100",  name: "M Venusaur EX",  set: "XY - Evolutions",  setCode: "EVO",  number: "100/108",  rarity: "Ultra Rare",  language: "EN",  imageUrl: "https://images.pokemontcg.io/xy12/100_hires.png",  hp: "230",  type: "Grass",  stage: "Mega"}; const facts: CardDetailFact[] = [  { kind: "type", value: "Grass" },  { kind: "text", label: "HP", value: "230" },  { kind: "text", label: "Attack", value: "Crisis Vine - 120. Your opponent's Active Pokemon is now Paralyzed and Poisoned." },  { kind: "weakness", value: "Fire x2" }]; const collection = {  actionLabel: "Add to collection",  items: [    { id: "evo-100-lp", name: "Lightly Played", set: "XY - Evolutions", setCode: "EVO", number: "100/108", rarity: "Ultra Rare", condition: "Lightly Played", value: "$27.97", imageUrl: card.imageUrl }  ]}; const market = {  label: "Market price",  price: "$27.97",  deltaToday: "+2.16%"}; const history = {  values: [26.55, 27.1, 26.82, 27.32, 27.05, 27.74, 27.97],  baselineValue: 27.38,  range: "1D",  ranges: ["1D", "1W", "1M", "1Y", "ALL"]}; export function CardDetailDemo() {  const variants = ["Normal", "Holofoil", "Reverse Holofoil"];  const conditions = ["Near Mint", "Lightly Played", "Moderately Played"];  const dataSources = ["All", "Raw", "TCGplayer", "eBay"];  const graders = ["Ungraded", "PSA", "CGC"];  const grades = ["10", "9", "8"];  const [activeVariant, setActiveVariant] = useState("Holofoil");  const [activeCondition, setActiveCondition] = useState("Lightly Played");  const [activeDataSource, setActiveDataSource] = useState("All");  const [activeGrader, setActiveGrader] = useState("Ungraded");  const [activeGrade, setActiveGrade] = useState("10");  const [activeRange, setActiveRange] = useState("1D");  const [isOwned, setIsOwned] = useState(false);   return (    <CardDetail      card={card}      facts={facts}      variants={variants}      activeVariant={activeVariant}      conditions={conditions}      activeCondition={activeCondition}      dataSources={dataSources}      activeDataSource={activeDataSource}      graders={graders}      activeGrader={activeGrader}      grades={grades}      activeGrade={activeGrade}      collection={{        ...collection,        actionLabel: isOwned ? "In collection" : collection.actionLabel,        items: isOwned ? collection.items : []      }}      market={market}      history={{ ...history, range: activeRange }}      onCollectionAction={() => setIsOwned(true)}      onConditionChange={setActiveCondition}      onDataSourceChange={setActiveDataSource}      onGraderChange={setActiveGrader}      onGradeChange={setActiveGrade}      onVariantChange={setActiveVariant}      onRangeChange={setActiveRange}    />  );}

Installation

Install the package.

npm install @cardspark/ui

Import the shared stylesheet layer near your app root.

app/layout.tsx
import "@cardspark/ui/tokens.css";import "@cardspark/ui/themes/basement.css";import "@cardspark/ui/styles.css";

Import and render CardDetail from its package entry point.

CardDetailDemo.tsx
"use client"; import { useState } from "react";import { CardDetail, type CardDetailFact } from "@cardspark/ui/layouts"; const card = {  id: "evo-100",  name: "M Venusaur EX",  set: "XY - Evolutions",  setCode: "EVO",  number: "100/108",  rarity: "Ultra Rare",  language: "EN",  imageUrl: "https://images.pokemontcg.io/xy12/100_hires.png",  hp: "230",  type: "Grass",  stage: "Mega"}; const facts: CardDetailFact[] = [  { kind: "type", value: "Grass" },  { kind: "text", label: "HP", value: "230" },  { kind: "text", label: "Attack", value: "Crisis Vine - 120. Your opponent's Active Pokemon is now Paralyzed and Poisoned." },  { kind: "weakness", value: "Fire x2" }]; const collection = {  actionLabel: "Add to collection",  items: [    { id: "evo-100-lp", name: "Lightly Played", set: "XY - Evolutions", setCode: "EVO", number: "100/108", rarity: "Ultra Rare", condition: "Lightly Played", value: "$27.97", imageUrl: card.imageUrl }  ]}; const market = {  label: "Market price",  price: "$27.97",  deltaToday: "+2.16%"}; const history = {  values: [26.55, 27.1, 26.82, 27.32, 27.05, 27.74, 27.97],  baselineValue: 27.38,  range: "1D",  ranges: ["1D", "1W", "1M", "1Y", "ALL"]}; export function CardDetailDemo() {  const variants = ["Normal", "Holofoil", "Reverse Holofoil"];  const conditions = ["Near Mint", "Lightly Played", "Moderately Played"];  const dataSources = ["All", "Raw", "TCGplayer", "eBay"];  const graders = ["Ungraded", "PSA", "CGC"];  const grades = ["10", "9", "8"];  const [activeVariant, setActiveVariant] = useState("Holofoil");  const [activeCondition, setActiveCondition] = useState("Lightly Played");  const [activeDataSource, setActiveDataSource] = useState("All");  const [activeGrader, setActiveGrader] = useState("Ungraded");  const [activeGrade, setActiveGrade] = useState("10");  const [activeRange, setActiveRange] = useState("1D");  const [isOwned, setIsOwned] = useState(false);   return (    <CardDetail      card={card}      facts={facts}      variants={variants}      activeVariant={activeVariant}      conditions={conditions}      activeCondition={activeCondition}      dataSources={dataSources}      activeDataSource={activeDataSource}      graders={graders}      activeGrader={activeGrader}      grades={grades}      activeGrade={activeGrade}      collection={{        ...collection,        actionLabel: isOwned ? "In collection" : collection.actionLabel,        items: isOwned ? collection.items : []      }}      market={market}      history={{ ...history, range: activeRange }}      onCollectionAction={() => setIsOwned(true)}      onConditionChange={setActiveCondition}      onDataSourceChange={setActiveDataSource}      onGraderChange={setActiveGrader}      onGradeChange={setActiveGrade}      onVariantChange={setActiveVariant}      onRangeChange={setActiveRange}    />  );}

Usage

CardDetailDemo.tsx
"use client"; import { useState } from "react";import { CardDetail, type CardDetailFact } from "@cardspark/ui/layouts"; const card = {  id: "evo-100",  name: "M Venusaur EX",  set: "XY - Evolutions",  setCode: "EVO",  number: "100/108",  rarity: "Ultra Rare",  language: "EN",  imageUrl: "https://images.pokemontcg.io/xy12/100_hires.png",  hp: "230",  type: "Grass",  stage: "Mega"}; const facts: CardDetailFact[] = [  { kind: "type", value: "Grass" },  { kind: "text", label: "HP", value: "230" },  { kind: "text", label: "Attack", value: "Crisis Vine - 120. Your opponent's Active Pokemon is now Paralyzed and Poisoned." },  { kind: "weakness", value: "Fire x2" }]; const collection = {  actionLabel: "Add to collection",  items: [    { id: "evo-100-lp", name: "Lightly Played", set: "XY - Evolutions", setCode: "EVO", number: "100/108", rarity: "Ultra Rare", condition: "Lightly Played", value: "$27.97", imageUrl: card.imageUrl }  ]}; const market = {  label: "Market price",  price: "$27.97",  deltaToday: "+2.16%"}; const history = {  values: [26.55, 27.1, 26.82, 27.32, 27.05, 27.74, 27.97],  baselineValue: 27.38,  range: "1D",  ranges: ["1D", "1W", "1M", "1Y", "ALL"]}; export function CardDetailDemo() {  const variants = ["Normal", "Holofoil", "Reverse Holofoil"];  const conditions = ["Near Mint", "Lightly Played", "Moderately Played"];  const dataSources = ["All", "Raw", "TCGplayer", "eBay"];  const graders = ["Ungraded", "PSA", "CGC"];  const grades = ["10", "9", "8"];  const [activeVariant, setActiveVariant] = useState("Holofoil");  const [activeCondition, setActiveCondition] = useState("Lightly Played");  const [activeDataSource, setActiveDataSource] = useState("All");  const [activeGrader, setActiveGrader] = useState("Ungraded");  const [activeGrade, setActiveGrade] = useState("10");  const [activeRange, setActiveRange] = useState("1D");  const [isOwned, setIsOwned] = useState(false);   return (    <CardDetail      card={card}      facts={facts}      variants={variants}      activeVariant={activeVariant}      conditions={conditions}      activeCondition={activeCondition}      dataSources={dataSources}      activeDataSource={activeDataSource}      graders={graders}      activeGrader={activeGrader}      grades={grades}      activeGrade={activeGrade}      collection={{        ...collection,        actionLabel: isOwned ? "In collection" : collection.actionLabel,        items: isOwned ? collection.items : []      }}      market={market}      history={{ ...history, range: activeRange }}      onCollectionAction={() => setIsOwned(true)}      onConditionChange={setActiveCondition}      onDataSourceChange={setActiveDataSource}      onGraderChange={setActiveGrader}      onGradeChange={setActiveGrade}      onVariantChange={setActiveVariant}      onRangeChange={setActiveRange}    />  );}

Composition

Use CardDetail for a focused card surface, supplying only the facts, collection, and market-history sections the page has.

CardDetailPage└── CardDetail    ├── card identity and artwork    ├── variant, condition, source, grader, and grade controls    ├── optional collection state and owned rows    ├── optional structured facts and rules text    └── optional market summary and price history

Pass `market` and `history` together to render price history. Type, weakness, and rules facts use explicit `kind` values; other facts use `kind="text"`.

Features

  • Composes canonical card data with independently optional collection, market plus history, and structured facts sections.
  • Keeps grader and grade state separate while hiding condition or grade controls when they do not apply.
  • Forwards native article props and refs and exposes focused callbacks for each controlled selection.

API Reference

PropTypeDefaultDescription
cardCardDetailCard-Canonical card requiring stable `id`, `name`, `set`, `number`, and `rarity` fields.
factsCardDetailFact[][]Structured text, type, weakness, and rules facts rendered when supplied.
variantsstring[]-Variant options available to the detail controls.
activeVariantstring-Currently selected card variant.
conditionsstring[]-Condition options available to the detail controls.
activeConditionstring-Currently selected condition value.
gradesstring[][]Grade options available when a graded grader is active.
activeGradestring-Currently selected numeric or named grade.
dataSourcesstring[]-Data source options available to the detail controls.
activeDataSourcestring-Currently selected data source value.
gradersstring[]-Grader options available to the detail controls.
activeGraderstring-Currently selected grader value.
collectionCardDetailCollection-Optional collection action and stable-id owned-copy rows.
marketCardDetailMarketSummary-Formatted market summary. Rendered with price history only when `history` is also supplied.
historyCardDetailHistory-Optional chart values, ranges, and dimension-specific data sets; rendered when `market` is also supplied.
onCollectionAction(event: MouseEvent<HTMLElement>) => void-Callback fired with the native event when the primary collection action is activated.
onConditionChange(condition: string) => void-Callback fired when the active condition changes.
onGradeChange(grade: string) => void-Callback fired when the active grade changes.
onDataSourceChange(dataSource: string) => void-Callback fired when the active market data source changes.
onGraderChange(grader: string) => void-Callback fired when the active grader changes.
onVariantChange(variant: string) => void-Callback fired when the active variant changes.
onRangeChange(range: string) => void-Callback fired when a chart range changes.