component — display

Index List

A numbered list ruled by hairlines under a strong top rule — mono number, Outfit title, caption meta — for work, articles or anything people pick from. Rows with an href shift right and lift to the raised ground on hover.

  • list
  • index
  • navigation
  • portfolio
  • links
  • brand

use a index list when

  • A list of work, articles or chapters, each one a link to its own page.
  • A short numbered sequence that should read as an editorial index rather than a table.

don't

  • Rows the user selects or acts on in place — that is a GridList.
  • Rows with more than a title and one piece of meta — that is a Table.
  • Don't give some rows an href and leave others static in one list; the rows that move read as the only real ones.

03 examples

Selected work

A display-s heading and an eyebrow count above it. Every row is a link: it shifts right and lifts on hover.

Static rows

Without an href a row is plain text and does not move on hover.

  1. 01discoverytwo weeks
  2. 02first releasesix weeks
  3. 03productionongoing

Custom numbers

`number` replaces the zero-padded position — a year, an issue, a letter.

what to use instead

by job

By what the index list was doing.

A list the user interacts with
  • GridList — Rows the user acts on.
  • Table — Rows with several fields.

source

index-list.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

import { Link } from "@/components/link"
import { cn } from "@/lib/utils"

/**
 * IndexList — quebi design system
 *
 * A numbered list ruled by hairlines, for work, articles or anything people
 * pick from: a mono number, a title in Outfit, a caption of meta on the right.
 * The list opens on the strong rule, the rows close on hairlines.
 *
 * A row with an `href` is a link across its full width, and answers hover by
 * shifting right and lifting to the raised ground — the one place in the
 * system a row moves. A row without one is static text and does not move, so
 * nothing looks clickable that is not.
 *
 * Put a `display-s` heading and an `Eyebrow` count ("04 entries") above it.
 * For rows the user selects or acts on in place, use `GridList`; for rows with
 * many fields, a `Table`.
 */

export interface IndexListItem {
  /** Stable key. Defaults to the row's number. */
  id?: string
  title: React.ReactNode
  meta?: React.ReactNode
  href?: string
  /** Shown in the number column. Defaults to the position, zero-padded: 01, 02, …. */
  number?: string
}

export interface IndexListProps extends Omit<React.HTMLAttributes<HTMLOListElement>, "children"> {
  items: IndexListItem[]
}

// Below `sm` the meta drops under the title instead of squeezing it: a
// three-column row at 375px leaves the title a few characters wide.
const ROW =
  "grid grid-cols-[2.5rem_1fr] items-baseline gap-x-3.5 gap-y-1 px-1 py-3.5 text-quebi-fg sm:grid-cols-[2.5rem_1fr_auto]"

/** Restates every part of the Link's own look the row does not want: it is a row, not prose. */
const LINK_ROW = [
  "font-sans font-normal no-underline hover:no-underline hover:text-quebi-fg",
  "transition-[padding,background-color] duration-300 ease-out",
  "hover:bg-quebi-raised hover:pl-3",
  "focus-visible:ring-inset focus-visible:ring-offset-0",
]

export function IndexList({ items, className, ...props }: IndexListProps) {
  return (
    <ol
      data-slot="index-list"
      className={cn("m-0 list-none border-t border-quebi-rule p-0", className)}
      {...props}
    >
      {items.map((item, position) => {
        const number = item.number ?? String(position + 1).padStart(2, "0")
        const cells = (
          <>
            <span className="font-mono text-xs text-quebi-fg-subtle">{number}</span>
            <span className="min-w-0 wrap-break-word font-display text-quebi-title">{item.title}</span>
            {item.meta != null ? (
              <span className="col-start-2 min-w-0 text-quebi-caption text-quebi-fg-subtle sm:col-start-auto sm:text-right">{item.meta}</span>
            ) : (
              <span className="hidden sm:block" />
            )}
          </>
        )
        return (
          <li key={item.id ?? number} data-slot="index-list-item" className="border-b border-quebi-hairline">
            {item.href ? (
              <Link href={item.href} className={cn(ROW, LINK_ROW)}>
                {cells}
              </Link>
            ) : (
              <div className={ROW}>{cells}</div>
            )}
          </li>
        )
      })}
    </ol>
  )
}