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
hrefand 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.
- 01discoverytwo weeks
- 02first releasesix weeks
- 03productionongoing
Custom numbers
`number` replaces the zero-padded position — a year, an issue, a letter.
what to use instead
by jobBy what the index list was doing.
source
index-list.tsxCopy 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>
)
}