← ui

category-tile

ui

a genre or a collection as a lockup on a dark tile, lighter when open

npx shadcn add @ag/category-tile
Already installed? Preview an update
npx shadcn add @ag/category-tile --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-05
  • New: a genre or a collection's lockup on a tile of dark glass, lighter when open: <CategoryTile open>{lockup}</CategoryTile>. A row of them is a Shelf with layout="tiles" and hideTitle, which rings the open one.
Updated 2026-11-05 · release notes

also called genre tile

shelf media

uses shelf

the demo above, verbatim

category-tile.demo.tsx
"use client"

import { CATEGORIES, category } from "@/app/browse-titles"
import { Sample } from "@/app/samples"
import { CategoryTile } from "@/registry/base-nova/ui/category-tile"
import { Shelf } from "@/registry/base-nova/ui/shelf"

export default function Demo() {
  return (
    <div className="space-y-12">
      <Sample name="tiles" glance title="one open, one at rest">
        <div className="dark flex gap-3 bg-black p-4 text-white">
          <CategoryTile open height="4.5rem">
            {category("dramas").lockup}
          </CategoryTile>
          <CategoryTile open={false} height="4.5rem">
            {category("thrillers").lockup}
          </CategoryTile>
        </div>
      </Sample>
      <Sample
        name="row"
        title="a row of categories"
        description="a shelf of tiles, its title hidden; ← → once the row has focus"
      >
        <div className="dark @container -mx-4 bg-black text-white">
          <Shelf
            label="Categories"
            hideTitle
            layout="tiles"
            items={CATEGORIES}
            itemKey={(c) => c.id}
            itemLabel={(c) => c.label}
          >
            {(c, { open }) => (
              <CategoryTile open={open}>{c.lockup}</CategoryTile>
            )}
          </Shelf>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

category-tile.tsx
import type * as React from "react"
import { cn } from "@/lib/utils"
import { CARD_LAYER } from "@/registry/base-nova/lib/shelf-motion"

// A CATEGORY AS A TILE: a genre or a collection's lockup (its name set as type, a
// mark) centred on a tile of dark glass, the way a browse page lines its categories up
// under the billboard (163 by 82 px of 1087 on a TV, its corner 9). Open, its glass is
// lighter (35 to 17 at rest, 53 to 31 open, a lit top edge on both); the ring is its
// shelf's. A row of them is a `shelf` of layout "tiles" with its title hidden. It is a
// drawing: what it is pressed as is its shelf's. Its size is its box's, or `height`
// alone (its width twice that); the lockup is a share of its height.

export function CategoryTile({
  open,
  children,
  height,
  className,
}: {
  open: boolean
  /** The lockup: words styled as the category's own type, or a mark. */
  children: React.ReactNode
  /** Its height alone, outside a shelf (any CSS length). */
  height?: string
  className?: string
}) {
  // The box is the container its insides measure (a container's own lengths would
  // read the one above it).
  return (
    <div
      data-slot="category-tile"
      data-open={open || undefined}
      className={cn(
        "relative [container-type:size]",
        height ? "aspect-[1.9877] h-(--tile-h)" : "size-full",
        className,
      )}
      style={
        height ? ({ "--tile-h": height } as React.CSSProperties) : undefined
      }
    >
      <div
        className={cn(
          "absolute inset-0 grid place-items-center overflow-hidden rounded-[10.98cqh] bg-linear-to-b px-[16cqh] text-center font-black text-[27cqh] text-white leading-none shadow-[inset_0_1px_0_rgb(255_255_255/0.36)]",
          open ? "from-white/21 to-white/11" : "from-white/14 to-white/7",
          // The whole tile is the picture a shelf's step fades and moves.
          CARD_LAYER.art,
        )}
      >
        {children}
      </div>
    </div>
  )
}