Link list
Lists of links: a side menu with an active item, grouped settings rows and a quiet resource list.
One of 6 components in Navigation elements
Variants
Details
What Link list brings along, read from the registry and the source.
- Category
- Navigation elements
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/link-list.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The LinkListProps interface from components/ui/link-list.tsx, with the comments from the code.
sectionsrequiredLinkListSection[]variantoptional'nav' | 'rows' | 'plain'nav: a side menu with an active item. rows: grouped rows with chevrons, like phone settings. plain: a quiet list of links with arrows.
labeloptionalstringAccessible name when it is a navigation
Accessibility
Read from the source of the component.
- Sets aria-current and aria-label, so screen readers announce purpose and state.
- Shows a visible focus ring when used with the keyboard.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 184 lines
'use client';
import { useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
export interface LinkListItem {
label: string;
href: string;
icon?: IconName;
description?: React.ReactNode;
/** A count or short status at the end */
badge?: React.ReactNode;
/** Text on the right, e.g. the current value of a setting */
value?: React.ReactNode;
active?: boolean;
external?: boolean;
destructive?: boolean;
}
export interface LinkListSection {
title?: string;
items: LinkListItem[];
}
export interface LinkListProps {
sections: LinkListSection[];
/** nav: a side menu with an active item. rows: grouped rows with chevrons, like phone settings. plain: a quiet list of links with arrows. */
variant?: 'nav' | 'rows' | 'plain';
/** Accessible name when it is a navigation */
label?: string;
className?: string;
}
/* Rows rise one after another when the list scrolls into view */
const STYLES =
'.kitfx-ll-in .kitfx-ll-row{animation:kitfx-ll-in .55s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 45ms)}' +
'@keyframes kitfx-ll-in{from{opacity:0;transform:translateY(8px)}}' +
'.kitfx-ll-in .kitfx-ll-bar{animation:kitfx-ll-bar .5s cubic-bezier(.34,1.56,.64,1) both;animation-delay:calc(var(--i,0) * 45ms + 250ms)}' +
'@keyframes kitfx-ll-bar{from{transform:scaleY(0)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-ll-in .kitfx-ll-row,.kitfx-ll-in .kitfx-ll-bar{animation:none}}';
/** In a side menu one soft surface follows the pointer and the focus from row to row */
function useGlide() {
const [glide, setGlide] = useState<{ y: number; h: number; on: boolean; jump: boolean } | null>(null);
const to = (el: HTMLElement | null) => {
if (!el || prefersReducedMotion()) return;
const y = el.offsetTop;
const h = el.offsetHeight;
setGlide((g) => ({ y, h, on: true, jump: !g || !g.on }));
};
const off = () => setGlide((g) => (g ? { ...g, on: false } : g));
return [glide, to, off] as const;
}
function NavSection({ items, start }: { items: LinkListItem[]; start: number }) {
const [glide, to, off] = useGlide();
return (
<ul className="relative isolate grid gap-0.5" onPointerLeave={off} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && off()}>
{glide && (
<span
aria-hidden
className={cn(
'pointer-events-none absolute inset-x-0 top-0 -z-10 rounded-lg bg-[color-mix(in_oklab,var(--accent)_70%,transparent)] transition-[opacity,transform] ease-[var(--ease-kit)]',
glide.jump ? 'duration-150 [transition-property:opacity]' : 'duration-300',
glide.on ? 'opacity-100' : 'opacity-0',
)}
style={{ height: glide.h, transform: `translateY(${glide.y}px)` }}
/>
)}
{items.map((it, i) => (
<li
key={it.label}
className="kitfx-ll-row"
style={{ '--i': start + i } as React.CSSProperties}
onPointerEnter={(e) => e.pointerType === 'mouse' && to(e.currentTarget)}
onFocus={(e) => to(e.currentTarget)}
>
<Row it={it} variant="nav" order={start + i} />
</li>
))}
</ul>
);
}
function Row({ it, variant, order }: { it: LinkListItem; variant: 'nav' | 'rows' | 'plain'; order: number }) {
return (
<a
href={it.href}
aria-current={it.active ? 'page' : undefined}
{...(it.external ? { target: '_blank', rel: 'noreferrer' } : {})}
className={cn(
'group/link flex items-center gap-3 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
variant === 'nav' && cn('relative min-h-10 rounded-lg px-3 text-sm max-md:min-h-11', it.active ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:text-foreground'),
variant === 'rows' && cn('min-h-[3.25rem] px-4 py-2.5 text-[15px] hover:bg-[color-mix(in_oklab,var(--accent)_60%,transparent)]', it.destructive ? 'text-destructive' : 'text-foreground'),
variant === 'plain' && 'min-h-14 py-3 text-[15px] text-foreground hover:text-foreground',
)}
>
{variant === 'nav' && it.active && <span aria-hidden className="kitfx-ll-bar absolute inset-y-2.5 start-1 w-[3px] origin-center rounded-full bg-primary" style={{ '--i': order } as React.CSSProperties} />}
{it.icon &&
(variant === 'rows' ? (
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-surface transition-[scale,color] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover/link:scale-105 motion-reduce:transition-none', it.destructive ? 'text-destructive' : 'text-muted-foreground group-hover/link:text-foreground')}>
<Icon name={it.icon} size={16} />
</span>
) : (
<Icon name={it.icon} size={17} className={cn('shrink-0 transition-[color,translate] duration-200 ease-[var(--ease-kit)] group-hover/link:translate-x-0.5 motion-reduce:transition-none', it.active ? 'text-foreground' : 'text-subtle-foreground group-hover/link:text-muted-foreground')} />
))}
<span className="min-w-0 flex-1">
<span className={cn('block truncate', variant === 'plain' && 'font-medium')}>
{variant === 'plain' ? (
// The underline grows from the start under the label
<span className="relative after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-left after:scale-x-0 after:bg-current after:opacity-50 after:transition-transform after:duration-300 after:ease-[var(--ease-kit)] group-hover/link:after:scale-x-100 group-focus-visible/link:after:scale-x-100 motion-reduce:after:transition-none">{it.label}</span>
) : (
it.label
)}
</span>
{it.description && <span className="mt-0.5 block text-pretty text-[13px] font-normal text-muted-foreground">{it.description}</span>}
</span>
{it.value && <span className="max-w-[45%] shrink-0 truncate text-sm text-muted-foreground">{it.value}</span>}
{it.badge !== undefined && (
<span className={cn('grid h-5 min-w-5 shrink-0 place-items-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums', it.active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')}>{it.badge}</span>
)}
{variant !== 'nav' && (
<Icon
name={it.external ? 'arrow-up-right' : variant === 'rows' ? 'chevron-right' : 'arrow-right'}
size={16}
className={cn(
'shrink-0 text-subtle-foreground transition-[translate,color] duration-300 ease-[var(--ease-kit)] group-hover/link:text-foreground group-focus-visible/link:text-foreground motion-reduce:transition-none',
it.external ? 'group-hover/link:-translate-y-0.5 group-hover/link:translate-x-0.5' : 'group-hover/link:translate-x-1 group-focus-visible/link:translate-x-1',
)}
/>
)}
</a>
);
}
/**
* Lists of links: side navigation, settings rows or a resource list. Rows arrive one after another,
* arrows glide on hover, in the side menu a soft surface follows the pointer.
* <LinkList variant="rows" sections={[{ title: 'Account', items: [{ label: 'Profile', href: '#', icon: 'user', value: 'Mia' }] }]} />
*/
export function LinkList({ sections, variant = 'nav', label, className }: LinkListProps) {
const Wrap = (variant === 'nav' ? 'nav' : 'div') as 'nav';
const [ref, inView] = useInView<HTMLElement>();
const waited = useRef(false);
if (!inView) waited.current = true;
const entering = inView && waited.current;
let order = 0;
return (
<Wrap
ref={ref as React.Ref<HTMLElement>}
aria-label={variant === 'nav' ? label : undefined}
className={cn('grid w-full grid-cols-[minmax(0,1fr)]', variant === 'rows' ? 'gap-7' : 'gap-6', !inView && '[&_.kitfx-ll-row]:opacity-0', entering && 'kitfx-ll-in', className)}
>
<style href="kitfx-ll" precedence="default">
{STYLES}
</style>
{sections.map((sec, si) => {
const start = order;
order += sec.items.length + (sec.title ? 1 : 0);
return (
<div key={sec.title ?? si}>
{sec.title && (
<p className={cn('kitfx-ll-row text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground', variant === 'rows' ? 'mb-2 px-4' : 'mb-2 px-3')} style={{ '--i': start } as React.CSSProperties}>
{sec.title}
</p>
)}
{variant === 'nav' ? (
<NavSection items={sec.items} start={start + (sec.title ? 1 : 0)} />
) : (
<ul className={cn(variant === 'rows' && 'divide-y divide-border overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', variant === 'plain' && 'divide-y divide-border border-y border-border')}>
{sec.items.map((it, i) => (
<li key={it.label} className="kitfx-ll-row" style={{ '--i': start + (sec.title ? 1 : 0) + i } as React.CSSProperties}>
<Row it={it} variant={variant} order={start + i} />
</li>
))}
</ul>
)}
</div>
);
})}
</Wrap>
);
}Frequent questions
How do I install Link list?
With aiw add @ai-website/link-list. The command puts components/ui/link-list.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Link list have?
3 variants, Side menu, Settings rows and Resource list. You pick one with the variant prop, for example variant="plain".
Can an AI assistant build with Link list?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Link list with aiw_components and install it with aiw_add @ai-website/link-list. Name the variant in your prompt, for example variant="rows", and the assistant writes your content straight into the props.