{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "list",
  "type": "registry:ui",
  "title": "List and cards",
  "description": "Airy project rows and raised cards that arrive in one staggered cascade. Filtering and inserting stay responsive, and hover never moves anything.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/list-cover.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/states.json",
    "https://littmd.vercel.app/r/tag.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/list.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/list 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * List and cards: project rows, project cards, filter chips and the empty state.\n *\n * \"Placed, not thrown\": things arrive with intent and settle.\n *\n * - ProjectList is the page's ONE expressive moment. Rows fade in and rise from\n *   the travel token's distance to rest, each waiting index x stagger. The cascade is\n *   capped at six, so row 7 onward arrive with row 6 and a long list never drags.\n * - ProjectGrid is responsive. Filtering re-flows the grid with Motion `layout`\n *   (position only, so text never stretches). Leaving cards fade and shrink to\n *   0.98 with no travel; added cards enter on the same tier.\n * - Hover is the instant tier and only changes fills and the hairline color.\n *   Rows and cards never move, and selection only turns the card's ring to the\n *   focus color.\n * - Touch has no hover, so on a coarse pointer a row (and an unselected filter chip) that is held\n *   down fills (PRESS_FILL, color only, instant tier), and a pressed card shows its hover steps.\n *   The box never moves, and the OS tap flash is off so the two do not double.\n * - Corners: the list container and cards are rounded-litt-surface; the rows inset 4px\n *   inside the container and the card cover inset 4px inside the card are\n *   rounded-litt-control (concentric), and so are the chips and tags.\n */\nimport type { ReactNode } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useExit, useStagger, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { Button } from \"./button\";\nimport { Empty } from \"./states\";\nimport { Tag, type TagStatus } from \"./tag\";\nimport { ProjectCover, type CoverName } from \"./list-cover\";\n\nexport type { CoverName } from \"./list-cover\";\n\nexport type ProjectStatus = \"live\" | \"review\" | \"draft\" | \"idea\";\nexport type ProjectFilter = \"all\" | \"live\" | \"review\" | \"draft\";\n\n/**\n * What every component here reads. Only id, name, client, status and updated are required; leave out\n * the rest and their part of the row or card is left out too. Map your own data to this shape where you\n * render the list, and leave the installed file as it is.\n */\nexport type Project = {\n  /** Unique. It is the React key and what the selection tracks. */\n  id: string;\n  name: string;\n  /** The line under the name: a client, an owner, a team. */\n  client: string;\n  /** Last deploy, in words: \"Deployed 2 days ago\", \"No deploy yet\". Shown after the client on wider screens. */\n  deploy?: string;\n  /** Shown as a status tag: live, review, draft or idea. */\n  status: ProjectStatus;\n  /** When it was last touched. An ISO date (YYYY-MM-DD) shows as \"09 Oct\"; anything else (\"2024\", \"Q3\") shows as written. */\n  updated: string;\n  /** One line, on the card. */\n  description?: string;\n  /** Short labels on the card. */\n  tags?: string[];\n  /** Which drawn cover the card shows. Without one, the card has no cover. */\n  cover?: CoverName;\n  /** When set, the row is a link; otherwise it is a button. */\n  href?: string;\n};\n\n/** The entrance cascade stops counting at this many rows. */\nexport const CASCADE_CAP = 6;\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst ISO_DATE = /^\\d{4}-(\\d{2})-(\\d{2})$/;\n/** A machine-readable year, month or day (\"2024\", \"2024-10\", \"2024-10-09\"), which a <time> can carry. */\nconst DATETIME = /^\\d{4}(-\\d{2}(-\\d{2})?)?$/;\n\n/** \"2026-10-09\" becomes \"09 Oct\": always six characters, so tabular dates align. Anything that is not an ISO date (\"2024\", \"Q3\") stays as written. */\nexport function formatDate(value: string) {\n  const m = ISO_DATE.exec(value);\n  const month = m ? MONTHS[Number(m[1]) - 1] : undefined;\n  return m && month ? `${m[2]} ${month}` : value;\n}\n\n/** The date column: a <time> when the value is a real date or year, plain text otherwise. */\nfunction When({ value, className }: { value: string; className: string }) {\n  return DATETIME.test(value) ? (\n    <time dateTime={value} className={className}>\n      {formatDate(value)}\n    </time>\n  ) : (\n    <span className={className}>{value}</span>\n  );\n}\n\n/**\n * Whether a project shows under a filter. \"all\" shows everything; any other filter matches the status.\n *\n * @param p The project to test.\n * @param filter The filter chip that is on.\n */\nexport function matchesFilter(p: Project, filter: ProjectFilter) {\n  return filter === \"all\" || p.status === filter;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Tags                                                                       */\n/* -------------------------------------------------------------------------- */\n\n// The project statuses on Tag's own status set. Idea is neutral like Draft; their words tell them apart.\nconst STATUS: Record<ProjectStatus, { label: string; status: TagStatus }> = {\n  live: { label: \"Live\", status: \"success\" },\n  review: { label: \"Review\", status: \"warning\" },\n  draft: { label: \"Draft\", status: \"neutral\" },\n  idea: { label: \"Idea\", status: \"neutral\" },\n};\n\n/** A project's status as a Tag: the label in words, and the text in the status color, never the fill (components.md, Tag). */\nexport function StatusTag({\n  status,\n  className = \"\",\n}: {\n  /** Sets the label and the text color. Draft and Idea share a color; their words differ. */\n  status: ProjectStatus;\n  /** Extra classes for the tag. */\n  className?: string;\n}) {\n  const s = STATUS[status];\n  return (\n    <Tag status={s.status} className={className}>\n      {s.label}\n    </Tag>\n  );\n}\n\n/** A quiet pill for a label such as Framer or Site. */\nexport function ProjectTag({ children }: { children: ReactNode }) {\n  return <Tag>{children}</Tag>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* ProjectList: airy rows with the staggered entrance                         */\n/* -------------------------------------------------------------------------- */\n\n/** What a finger holding a row shows: fg 6%, a step above the 4% hover fill because the finger covers the row. */\nconst PRESS_FILL = \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)]\";\n\n/* The row reaches 1px past its bottom, over the divider, so a pointer between two rows is always on one. */\nconst rowClass =\n  \"relative flex size-full items-center gap-4 rounded-litt-control px-4 text-left after:absolute after:inset-x-0 after:-bottom-px after:h-px \" +\n  \"cursor-pointer transition-[background-color] duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent] \" +\n  \"hover:bg-[color-mix(in_oklab,var(--litt-fg)_4%,transparent)] in-[[data-material=flat]]:hover:bg-litt-surface-3 \" + PRESS_FILL + \" \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n\nfunction RowContent({ project }: { project: Project }) {\n  return (\n    <>\n      <span className=\"grid min-w-0 flex-1 gap-1\">\n        <span className=\"truncate text-litt-body font-litt-title text-litt-fg\">{project.name}</span>\n        <span className=\"truncate text-litt-caption text-litt-fg-subtle\">\n          {project.client}\n          {project.deploy && <span className=\"hidden sm:inline\"> · {project.deploy}</span>}\n        </span>\n      </span>\n      <span className=\"flex shrink-0 sm:w-24\">\n        <StatusTag status={project.status} />\n      </span>\n      <When\n        value={project.updated}\n        className=\"hidden min-w-14 shrink-0 text-right text-litt-caption font-normal whitespace-nowrap tabular-nums text-litt-fg-subtle sm:block\"\n      />\n    </>\n  );\n}\n\nexport type ProjectListProps = {\n  /** The rows, in order. Each row is a button, or a link when the project has an href. */\n  projects: Project[];\n  /** Called when a row without an href is pressed. */\n  onOpen?: (project: Project) => void;\n  /** Play the staggered entrance on mount. Remount (change `key`) to replay. */\n  entrance?: boolean;\n  /** The list's accessible name. Change it when the page has more than one list. */\n  label?: string;\n  /** Extra classes for the list, such as a max width. */\n  className?: string;\n};\n\n/** Airy project rows in one hairline box. The rows arrive in a staggered cascade, the page's one expressive moment. */\nexport function ProjectList({ projects, onOpen, entrance = true, label = \"Projects\", className = \"\" }: ProjectListProps) {\n  const enter = useTransition(\"expressive\");\n  const travel = useTravel();\n  const stagger = useStagger();\n  return (\n    <ul\n      aria-label={label}\n      className={\"w-full rounded-litt-surface p-1 shadow-[inset_0_0_0_1px_var(--litt-line)] in-[[data-material=flat]]:shadow-none \" + className}\n    >\n      {projects.map((p, i) => {\n        // Rows after the sixth reuse the sixth's delay.\n        const wait = Math.min(i, CASCADE_CAP - 1) * stagger;\n        return (\n          <motion.li\n            key={p.id}\n            className=\"h-16 border-b border-litt-line last:border-b-0 in-[[data-material=flat]]:border-transparent\"\n            initial={entrance ? { opacity: 0, y: travel } : false}\n            animate={{ opacity: 1, y: 0, transition: { ...enter, delay: wait } }}\n          >\n            {p.href ? (\n              <a href={p.href} className={rowClass}>\n                <RowContent project={p} />\n              </a>\n            ) : (\n              <button type=\"button\" onClick={() => onOpen?.(p)} className={rowClass}>\n                <RowContent project={p} />\n              </button>\n            )}\n          </motion.li>\n        );\n      })}\n    </ul>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* ProjectCard                                                                */\n/* -------------------------------------------------------------------------- */\n\nexport type ProjectCardProps = {\n  /** The project to show. */\n  project: Project;\n  /** Whether the card is selected. You own this state. */\n  selected?: boolean;\n  /** Called with the checkbox's new state. Leave it out and the checkbox cannot be changed. */\n  onSelectedChange?: (selected: boolean) => void;\n  /** Called when the card is pressed anywhere except the checkbox. */\n  onOpen?: (project: Project) => void;\n  /** Extra classes for the card. */\n  className?: string;\n};\n\n/**\n * A raised card: no shadow, nothing moves. Hover (instant tier) strengthens the\n * hairline (line to line-strong) and steps the cover fill (surface-2 to\n * surface-3). Touch has no hover, so on a coarse pointer a press shows the same\n * two steps (instant tier, color only). Selected turns the ring to 1px --litt-focus and\n * changes nothing else. The whole card opens through one stretched button; the\n * checkbox sits above it.\n */\nexport function ProjectCard({ project, selected = false, onSelectedChange, onOpen, className = \"\" }: ProjectCardProps) {\n  const select = (\n    <label className=\"absolute top-1 right-1 z-10 grid size-8 cursor-pointer place-items-center pointer-coarse:size-11\">\n      <input\n        type=\"checkbox\"\n        checked={selected}\n        onChange={(e) => onSelectedChange?.(e.target.checked)}\n        aria-label={`Select ${project.name}`}\n        className=\"size-4 cursor-pointer rounded-litt-control accent-litt-fg\"\n      />\n    </label>\n  );\n  return (\n    <article\n      data-selected={selected || undefined}\n      className={\"litt-raised group relative flex h-full flex-col rounded-litt-surface p-1 in-[[data-material=flat]]:shadow-litt-float \" + className}\n    >\n      {/* The ring. It changes color only, on the instant tier. */}\n      <span\n        aria-hidden=\"true\"\n        className={\n          \"pointer-events-none absolute inset-0 rounded-litt-surface border transition-[border-color] duration-(--litt-dur-instant) ease-litt \" +\n          (selected ? \"border-litt-focus\" : \"border-litt-line group-hover:border-litt-line-strong pointer-coarse:group-active:border-litt-line-strong in-[[data-material=flat]]:border-transparent in-[[data-material=flat]]:group-hover:border-transparent\")\n        }\n      />\n\n      {project.cover ? (\n        <div className=\"relative aspect-[8/5] shrink-0 overflow-hidden rounded-[calc(var(--litt-radius-surface)-4px)] bg-litt-surface-2 transition-[background-color] duration-(--litt-dur-instant) ease-litt group-hover:bg-litt-surface-3 pointer-coarse:group-active:bg-litt-surface-3\">\n          <ProjectCover name={project.cover} />\n          {select}\n        </div>\n      ) : (\n        select\n      )}\n\n      {/* Without a cover the select box sits in the card's corner, so the title keeps clear of it. */}\n      <div className={\"grid gap-1 px-3 pt-4 \" + (project.cover ? \"\" : \"pr-12\")}>\n        <h3 className=\"truncate text-litt-body font-litt-title text-litt-fg\">{project.name}</h3>\n        {project.description && <p className=\"truncate text-litt-small text-litt-fg-subtle\">{project.description}</p>}\n      </div>\n      <div className=\"mt-auto flex flex-wrap gap-2 px-3 pt-4 pb-3\">\n        <StatusTag status={project.status} />\n        {project.tags?.map((t) => (\n          <ProjectTag key={t}>{t}</ProjectTag>\n        ))}\n      </div>\n\n      <button\n        type=\"button\"\n        aria-label={`Open ${project.name}`}\n        onClick={() => onOpen?.(project)}\n        className=\"absolute inset-0 z-0 cursor-pointer rounded-litt-surface [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\"\n      />\n    </article>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Filter chips                                                               */\n/* -------------------------------------------------------------------------- */\n\nconst FILTERS: { id: ProjectFilter; label: string }[] = [\n  { id: \"all\", label: \"All\" },\n  { id: \"live\", label: \"Live\" },\n  { id: \"review\", label: \"Review\" },\n  { id: \"draft\", label: \"Draft\" },\n];\n\n/** Filter chips at the control radius, like the tags beside them. Selected is fg on surface-3; the rest are fg-subtle. Color and fill only. */\nexport function ProjectFilters({\n  value,\n  onChange,\n  className = \"\",\n}: {\n  /** The chip that is on: all, live, review or draft. You own this state. */\n  value: ProjectFilter;\n  /** Called with the chip that was pressed. */\n  onChange: (filter: ProjectFilter) => void;\n  /** Extra classes for the row of chips. */\n  className?: string;\n}) {\n  return (\n    <div role=\"group\" aria-label=\"Filter projects\" className={\"flex flex-wrap gap-2 \" + className}>\n      {FILTERS.map((f) => {\n        const on = value === f.id;\n        return (\n          <button\n            key={f.id}\n            type=\"button\"\n            aria-pressed={on}\n            onClick={() => onChange(f.id)}\n            className={\n              \"litt-target relative inline-flex h-litt-control-sm cursor-pointer items-center rounded-litt-control px-litt-control-pad-sm text-litt-small font-litt-title \" +\n              \"transition-[background-color,color] duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent] \" +\n              \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n              (on\n                ? \"bg-litt-surface-3 text-litt-fg\"\n                : \"text-litt-fg-subtle hover:text-litt-fg hover:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" + PRESS_FILL + \" pointer-coarse:active:text-litt-fg\")\n            }\n          >\n            <span className=\"litt-trim\">{f.label}</span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Empty state                                                                */\n/* -------------------------------------------------------------------------- */\n\nconst EMPTY: Record<ProjectFilter, { line: string; hint: string }> = {\n  all: { line: \"No projects yet.\", hint: \"A new project starts as a draft and shows up here.\" },\n  live: { line: \"Nothing is live yet.\", hint: \"A project appears here once it is deployed.\" },\n  review: { line: \"Nothing is in review.\", hint: \"Send a draft for review and it lands here.\" },\n  draft: { line: \"No drafts right now.\", hint: \"New projects start as drafts.\" },\n};\n\n/** The Empty state for a filter: one line in fg-muted, a hint in fg-subtle, and the way out. Left-aligned, no illustration. */\nexport function ProjectsEmpty({\n  filter,\n  onClearFilter,\n}: {\n  /** Picks the wording: each filter has its own line and hint. */\n  filter: ProjectFilter;\n  /** Called by the Clear filter button, which shows for every filter except all. Leave it out to hide the button. */\n  onClearFilter?: () => void;\n}) {\n  const copy = EMPTY[filter];\n  return (\n    <Empty\n      role=\"status\"\n      title={copy.line}\n      action={\n        filter !== \"all\" && onClearFilter ? (\n          // The ghost button's label is inset by its padding; pull it back so it lines up with the text above.\n          <Button variant=\"ghost\" size=\"sm\" onClick={onClearFilter} className=\"-ml-litt-control-pad-sm\">\n            Clear filter\n          </Button>\n        ) : undefined\n      }\n    >\n      {copy.hint}\n    </Empty>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* ProjectGrid: layout motion on the responsive tier                          */\n/* -------------------------------------------------------------------------- */\n\nexport type ProjectGridProps = {\n  /** Every project. The grid applies the filter itself, so it can animate the difference. */\n  projects: Project[];\n  /** Which projects show: all, or the ones with that status. Pair it with ProjectFilters. */\n  filter: ProjectFilter;\n  /** The ids of the selected cards. You own this state. */\n  selectedIds?: string[];\n  /** Called with a card's id and its checkbox's new state. */\n  onSelectedChange?: (id: string, selected: boolean) => void;\n  /** Called by the Clear filter button in the empty state. */\n  onClearFilter?: () => void;\n  /** Called when a card is pressed anywhere except the checkbox. */\n  onOpen?: (project: Project) => void;\n};\n\n/** A grid of ProjectCards. Changing the filter re-flows it: cards that stay slide, cards that leave fade, and an empty result shows ProjectsEmpty. */\nexport function ProjectGrid({ projects, filter, selectedIds = [], onSelectedChange, onClearFilter, onOpen }: ProjectGridProps) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const visible = projects.filter((p) => matchesFilter(p, filter));\n  const empty = visible.length === 0;\n  // The empty state waits for the leaving cards to finish fading, so the two never overlap.\n  const { duration: settle = 0 } = exit as { duration?: number };\n\n  return (\n    <div className=\"grid w-full\">\n      <div className=\"relative col-start-1 row-start-1 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] content-start gap-4\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {visible.map((p) => (\n            <motion.div\n              key={p.id}\n              layout=\"position\"\n              className=\"h-full\"\n              initial={{ opacity: 0, y: travel / 2, scale: 0.98 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.98, transition: exit }}\n              transition={enter}\n            >\n              <ProjectCard\n                project={p}\n                selected={selectedIds.includes(p.id)}\n                onSelectedChange={(on) => onSelectedChange?.(p.id, on)}\n                onOpen={onOpen}\n              />\n            </motion.div>\n          ))}\n        </AnimatePresence>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {empty && (\n          <motion.div\n            key=\"empty\"\n            className=\"col-start-1 row-start-1\"\n            initial={{ opacity: 0, y: travel / 2 }}\n            animate={{ opacity: 1, y: 0, transition: { ...enter, delay: settle } }}\n            exit={{ opacity: 0, transition: exit }}\n          >\n            <ProjectsEmpty filter={filter} onClearFilter={onClearFilter} />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "target": "components/litt/list.tsx"
    }
  ],
  "docs": "Import ProjectList, ProjectGrid, ProjectFilters and ProjectsEmpty from components/litt/list.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
