AI

Model Card

One model's spec sheet — context window, max output and cutoff, tri-state capability chips, per-million-token pricing with the cached-read discount, benchmark bars that switch to a signed comparison against a baseline, and a deprecation notice that names the successor.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDown,
  ArrowUp,
  AudioLines,
  BadgeCheck,
  Boxes,
  Braces,
  Brain,
  Check,
  CircleAlert,
  CircleSlash,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/model-card.json

Prompt

Build a React + TypeScript + Tailwind "ModelCard" component with zod and
lucide-react, reusing a shadcn Button and a copy-to-clipboard hook.

Contract
- A zod schema (`modelCardModelSchema` in a sibling contract file) is the single
  source of truth for ONE model: { id, name, provider?, description?,
  contextWindow?, maxOutputTokens?, knowledgeCutoff?, releasedOn?, capabilities?,
  pricing?, benchmarks?, lifecycle?, docsUrl? }. Only id and name are required —
  every block below the header is optional and simply disappears when its field
  is absent, so one component renders a fully documented frontier model AND a
  self-hosted open-weights checkpoint with no price and no benchmarks.
- capabilities: { id: string; label?: string; support?: "yes" | "no" | "beta" }[].
  The id is an OPEN string: known ids (vision, tools, json, caching, reasoning,
  audio, batch, fine-tuning) get a built-in icon and wording, anything else falls
  back to a generic mark plus the caller's label. Support is tri-state because a
  chip list that only shows what works cannot distinguish "no vision" from
  "nobody filled that field in", and that difference is why somebody is reading a
  spec card at all.
- pricing: { inputPerMTok, outputPerMTok, cachedInputPerMTok?, currency? } in
  MAJOR units per 1,000,000 tokens — 3 means $3.00/Mtok, 0.075 means 7.5 cents.
  Never pre-format them: the card divides them (cache discount, per-request
  estimate) and a string cannot be divided.
- benchmarks: { id, label, score, max? (100), unit?, higherIsBetter? (true),
  baseline?: { label, score }, note? }[]. Direction is PER ROW because a real
  sheet mixes directions — accuracy goes up, latency and hallucination rate go
  down — and a card that assumes "up is good" paints a regression as a win.
- lifecycle: { status: "ga" | "preview" | "deprecated" | "retired"; note?;
  sunsetOn?; replacement?: { id, name } }. Absent means ga: no badge, no notice.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  card's own render state, independent of lifecycle. status="error" means the
  spec sheet failed to load; a retired model is a perfectly successful load of
  bad news, and the two read differently on screen.
- Props: model: Model | null; status; providerLogo? (ReactNode slot);
  headingLevel? (2-6, default 3); onSelect?(modelId); selectLabel? ("Use this
  model"); selected?; onSelectReplacement?(modelId); onRetry?;
  defaultBenchmarkView? ("absolute" | "delta"); estimateFor?: { inputTokens,
  outputTokens, label? }; formatTokens?; formatPrice?; emptyState?;
  errorMessage?; label? ("Model"); className plus the rest of the element props,
  ref forwarded to the <article>. Omit React's own onSelect from the extended
  HTML attributes so the two never collide.

Behavior
- BENCHMARKS HAVE TWO VIEWS AND ONE SCALE. "Score" draws each bar as
  score / max with an optional tick where the baseline sits; "vs <baseline>"
  draws a DIVERGING bar around a centre line whose length is
  |score - baseline| / (largest absolute delta on the card). Normalising each
  row to its own delta would draw a 0.4-point win and a 13-point win identically;
  one shared scale is what makes the rows comparable by eye. The toggle is only
  rendered when at least one benchmark carries a baseline, and the effective view
  falls back to "absolute" when none does, so the card can never get stuck on an
  empty comparison.
- Better is not "positive": with higherIsBetter=false a negative delta is the win
  and gets the chart colour, while the regression gets a neutral token. A delta
  of exactly zero is a third answer — neutral icon, neutral colour — never
  painted as an improvement.
- Values are clamped, never hidden: a score above its max fills the bar and still
  PRINTS the real number (104 on a scale of 100), a negative score empties it, a
  max of 0 yields an empty bar instead of a division by zero, and a row with no
  baseline says "No baseline for this row" in the delta view instead of drawing
  nothing.
- LIFECYCLE GATES THE PRIMARY ACTION. "retired" means the model no longer
  answers, so onSelect is refused: the button stays focusable, is aria-disabled,
  and is aria-describedby the notice that explains why — a native `disabled`
  attribute would remove it from the tab order, so a keyboard reader would never
  reach the explanation. Every path out of a retired or deprecated model goes
  through the successor button, which is only rendered when both
  lifecycle.replacement and onSelectReplacement exist.
- No handler, no control: with no onSelect the action row is not rendered, with
  no docsUrl no link is drawn, with no onRetry the error branch has no button. A
  button that does nothing is worse than no button.
- The model id is copyable, because the id — not the pretty name — is what gets
  pasted into code. Copy state is reported through a permanently mounted sr-only
  live region rather than by mutating the button's label, since a label that
  changes on an already-focused button is not reliably re-announced. A blocked
  clipboard flips to a failure icon instead of failing silently.
- Identity changes reset the card by adjusting state during render (no effect):
  a new model.id restores the default benchmark view, so a recycled card never
  shows the previous model's comparison for a frame.
- The card owns no clock, no fetch and no observer. Its only timer lives inside
  the copy hook, which clears it on unmount. Rendering is a pure function of the
  contract, so the server, a hydrated page and a screenshot fixture all agree.
- Numbers are formatted with a pinned en-US locale, not the ambient one: a server
  and a browser in different locales would otherwise disagree on the exact string
  and trip a hydration error. Token counts are compact ("200K"), money keeps two
  to four decimals so a $0.075/Mtok rate is not rounded up to $0.08 (a 7%
  overstatement), an exact 0 prints as "Free", and an unknown currency code falls
  back to USD formatting instead of throwing. Dates in YYYY-MM / YYYY-MM-DD are
  formatted in UTC; anything else ("early 2026") is printed verbatim, because a
  string you could not parse is still an answer the reader wants.
- The estimate line is arithmetic on the contract, not a request:
  inputTokens/1e6 * inputPerMTok + outputTokens/1e6 * outputPerMTok, shown only
  when pricing exists and the numbers are finite and non-negative. The cached
  rate is shown with the discount it implies (1 - cached/input), suppressed below
  half a percent so rounding noise never reads as a deal.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-muted/40 for the price panel, bg-primary/5 + border-primary/50 for preview
  and "current model", text-destructive + bg-destructive/5 for the deprecation
  notice, bg-foreground/60 for the baseline tick, and var(--chart-1..5) cycled by
  the benchmark's index in the incoming array so re-ordering data never repaints
  an already-read row. No hard-coded colours anywhere.
- States are SHAPES as well as colours: capability chips are plain / dashed with
  "beta" / struck through with a cross, lifecycle badges are flask / triangle /
  slash, and deltas are arrow-up / arrow-down / minus — each with an sr-only word,
  so a monochrome theme and a colour-blind reader lose nothing.
- The root is an <article aria-labelledby> pointing at the model name, whose
  heading level is a prop (default h3) so the card slots into the page's outline
  instead of dictating it. Specs are a real <dl>, capabilities a real <ul>,
  benchmarks a <section aria-labelledby>, and the view toggle a role="group" of
  two aria-pressed buttons.
- Every figure is visible as text, so the bars are aria-hidden decoration; the
  compact figure and the exact figure are two nodes (aria-hidden + sr-only), not
  one string with a title attribute, so nobody hears "200K tokens two hundred
  thousand tokens".
- Long names and ids use wrap-anywhere plus min-w-0, NOT break-words: only
  overflow-wrap:anywhere lowers the element's min-content width, which is what
  stops a 70-character checkpoint name from widening its grid column. Spec cells
  and price cells are auto-fit grids, so the card reflows down to ~20rem.
- Bars transition on width only, guarded by motion-reduce, and they render at
  their final width on the first paint — there is no mount animation, so an SSR
  page, a hydrated page and a screenshot are identical. The movement exists for
  the view SWITCH, where it carries meaning.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Blocks: every section is data-gated. Drop `pricing` and the price panel goes;
  drop `benchmarks` and the chart section goes; drop `capabilities`, `docsUrl` or
  the spec fields individually. Add rows by adding contract fields, not branches.
- Density: the specs and price rows are auto-fit grids — change the 8rem / 7rem
  floors to get more or fewer columns per row; drop `description` for a compact
  catalogue tile.
- Comparison: `defaultBenchmarkView` decides which view the card opens in; pass
  a per-row `baseline` only for the metrics you want compared, and use
  `higherIsBetter` for latency, cost and error-rate rows.
- Units: `formatTokens` and `formatPrice` swap the wording wholesale (per-1K
  rates, a localised currency, "128k ctx"), and `estimateFor` re-labels the
  worked example for your own typical request.
- Chrome: `providerLogo` is a free slot next to the name, `headingLevel` fits the
  page outline, `selectLabel` renames the primary action, `selected` turns it
  into a static "current model" mark, `emptyState` replaces the empty body, and
  `errorMessage` + `onRetry` own the envelope failure.
- Policy: extend CAPABILITY_META with your own ids (grounded search, computer
  use, PDF input) — or pass a `label` and skip the table entirely.

Concepts

  • A baseline turns a number into a decision — "82.4" is uncalibrated trivia; "+8.3 over the model you are running today" is something you can act on. The comparison is a VIEW of the same rows rather than a second card, so the reader never loses the absolute figure, and the toggle only exists when the data actually carries baselines.
  • One shared delta scale — every diverging bar is measured against the largest absolute delta on the card. Normalising each row to its own delta is the classic chart lie: a 0.4-point win and a 13-point win come out the same length, and the eye reads them as equal.
  • Direction is per row, not per card — accuracy goes up, latency and hallucination rate go down. higherIsBetter: false flips which side of the centre line earns the chart colour, so a spec sheet that mixes metrics never paints a regression as a win.
  • Absence and "no" are different answers — a capability chip is tri-state (yes / beta / no). A list that only shows what works cannot tell "this model has no vision" apart from "nobody filled that field in", and picking a model on that ambiguity is exactly the mistake the card exists to prevent.
  • Deprecation is a state of the model; the envelope is a state of the load — a retired model is a successful fetch of bad news, and it reads nothing like "the catalogue timed out". The retired card keeps its primary action focusable but aria-disabled and pointed at the notice that explains it, because a button removed from the tab order takes its explanation with it.
  • Compact is seen, exact is heard — "200K tokens" is what a human compares, "200,000 tokens" is what a screen reader should read. They are two nodes, not one string with a tooltip, so neither audience gets the other's version bolted onto theirs.

On This Page