List
A grouped list with rows, routing and custom templates.
A grouped list of rows with leading/trailing content, subtitles, chevrons, and an optional link mode.
Installation
npx touchcn add list
Usage
<tcn-list>
<tcn-list-item chevron>
<svg item-leading><!-- … --></svg>
Wi-Fi
<span item-subtitle>Connected</span>
</tcn-list-item>
<tcn-list-item [link]="['/about']" chevron>About</tcn-list-item>
</tcn-list>import { TcnList, TcnListItem } from '@/components/ui/list';
<TcnList>
<TcnListItem leading={<WifiIcon />} subtitle="Connected" chevron>
Wi-Fi
</TcnListItem>
<TcnListItem chevron>
<Link to="/about">About</Link>
</TcnListItem>
</TcnList><script setup lang="ts">
import { TcnList, TcnListItem } from '@/components/ui/list';
</script>
<template>
<TcnList>
<TcnListItem subtitle="Connected" chevron>
<template #leading><WifiIcon /></template>
Wi-Fi
</TcnListItem>
<TcnListItem chevron>
<RouterLink to="/about">About</RouterLink>
</TcnListItem>
</TcnList>
</template>Section header & footer
TcnListHeader and TcnListFooter are flat siblings of tcn-list — a header labels the
group above it (uppercase caption on iOS, sentence-case subheader on Material), a footer adds
a caption below it. The header takes an optional trailing slot for an action.
<tcn-list-header>
Notifications
<button header-trailing tcnButton variant="ghost" size="sm">See all</button>
</tcn-list-header>
<tcn-list>
<tcn-list-item>Push</tcn-list-item>
</tcn-list>
<tcn-list-footer>Choose how you want to be notified.</tcn-list-footer>import { TcnList, TcnListItem, TcnListHeader, TcnListFooter } from '@/components/ui/list';
<TcnListHeader trailing={<TcnButton variant="ghost" size="sm">See all</TcnButton>}>
Notifications
</TcnListHeader>
<TcnList>
<TcnListItem>Push</TcnListItem>
</TcnList>
<TcnListFooter>Choose how you want to be notified.</TcnListFooter><script setup lang="ts">
import { TcnList, TcnListItem, TcnListHeader, TcnListFooter } from '@/components/ui/list';
</script>
<template>
<TcnListHeader>
Notifications
<template #trailing>
<TcnButton variant="ghost" size="sm">See all</TcnButton>
</template>
</TcnListHeader>
<TcnList>
<TcnListItem>Push</TcnListItem>
</TcnList>
<TcnListFooter>Choose how you want to be notified.</TcnListFooter>
</template>Props
TcnListItem
| Prop | Type | Default | Description |
|---|---|---|---|
leading |
slot / ReactNode |
— | Leading content (Angular: [item-leading]). |
subtitle |
slot / ReactNode |
— | Secondary line (Angular: [item-subtitle]). |
trailing |
slot / ReactNode |
— | Trailing content (Angular: [item-trailing]). |
chevron |
boolean |
false |
Show a disclosure chevron. |
link |
string | unknown[] |
— | Render the whole row as a single link. |
TcnListHeader
| Prop | Type | Default | Description |
|---|---|---|---|
trailing |
slot / ReactNode |
— | Trailing action, end-aligned (Angular: [header-trailing]). |
Source · Angular
export * from './tcn-list';import { NgTemplateOutlet } from '@angular/common';
import { booleanAttribute, Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { warnOnInteractiveLinkedRow } from '@touchcn/angular';
import { TcnRippleDirective } from '@touchcn/angular/ripple';
@Component({
selector: 'tcn-list',
template: '<ng-content />',
host: { class: 'tcn-list block', '[attr.data-separators]': 'separators() ? null : "false"' },
})
export class TcnList {
/** Built-in row hairlines. Set false to suppress them (e.g. to show dividers). */
readonly separators = input(true, { transform: booleanAttribute });
}
@Component({
selector: 'tcn-list-item',
imports: [NgTemplateOutlet, RouterLink, TcnRippleDirective],
template: `
<ng-template #body>
<div class="flex shrink-0 items-center empty:hidden"><ng-content select="[item-leading]" /></div>
<div class="min-w-0 flex-1 py-2">
<div class="truncate">
<ng-content />
</div>
<div class="truncate text-sm text-[var(--color-on-surface-variant)] empty:hidden">
<ng-content select="[item-subtitle]" />
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5 text-[var(--color-on-surface-variant)]">
<ng-content select="[item-trailing]" />
@if (chevron()) {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
}
</div>
</ng-template>
@if (link(); as target) {
<a tcnRipple class="tcn-list-item-tappable tcn-list-item-row flex items-center gap-3 px-4" [routerLink]="target">
<ng-container [ngTemplateOutlet]="body" />
</a>
} @else if (chevron()) {
<div tcnRipple class="tcn-list-item-tappable tcn-list-item-row flex items-center gap-3 px-4">
<ng-container [ngTemplateOutlet]="body" />
</div>
} @else {
<div class="tcn-list-item-row flex items-center gap-3 px-4">
<ng-container [ngTemplateOutlet]="body" />
</div>
}
`,
host: { class: 'tcn-list-item block' },
})
export class TcnListItem {
readonly link = input<string | unknown[]>();
readonly chevron = input(false, { transform: booleanAttribute });
constructor() {
warnOnInteractiveLinkedRow(() => !!this.link());
}
}
@Component({
selector: 'tcn-list-header',
template: `<ng-content /><ng-content select="[header-trailing]" />`,
host: { class: 'tcn-list-header flex items-center justify-between gap-2' },
})
export class TcnListHeader {}
@Component({
selector: 'tcn-list-footer',
template: '<ng-content />',
host: { class: 'tcn-list-footer block' },
})
export class TcnListFooter {}Source · React
export * from './tcn-list';import { forwardRef, useRef } from 'react';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { cn } from '@touchcn/core';
import { mergeRefs, renderAsChildRow, useLinkedRowGuard, useRipple } from '@touchcn/react';
export interface TcnListProps extends ComponentPropsWithoutRef<'div'> {
/** Built-in row hairlines. Set false to suppress them (e.g. to show dividers). */
separators?: boolean;
}
export const TcnList = forwardRef<HTMLDivElement, TcnListProps>(function TcnList(
{ separators = true, className, children, ...props },
ref,
) {
return (
<div
ref={ref}
data-separators={separators ? undefined : 'false'}
className={cn('tcn-list block', className)}
{...props}
>
{children}
</div>
);
});
const Chevron = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export interface TcnListItemProps extends ComponentPropsWithoutRef<'div'> {
leading?: ReactNode;
subtitle?: ReactNode;
trailing?: ReactNode;
chevron?: boolean;
/**
* Link mode: render the whole row as the provided child anchor (e.g. a
* react-router `<Link>`). The child's own children become the row's primary
* content; `leading` / `subtitle` / `trailing` wrap around it.
*/
asChild?: boolean;
}
export const TcnListItem = forwardRef<HTMLDivElement, TcnListItemProps>(function TcnListItem(
{ leading, subtitle, trailing, chevron = false, asChild = false, className, children, ...props },
forwardedRef,
) {
const hostRef = useRef<HTMLDivElement>(null);
const { hostRef: rippleRef, hostClassName, rippleHandlers } = useRipple<HTMLElement>();
useLinkedRowGuard(hostRef, asChild);
const trailingCluster = (
<div className="flex shrink-0 items-center gap-1.5 text-[var(--color-on-surface-variant)]">
{trailing}
{chevron && <Chevron />}
</div>
);
const rowClass = 'tcn-list-item-tappable tcn-list-item-row flex items-center gap-3 px-4';
let row: ReactNode;
if (asChild) {
// The single child anchor (e.g. a router <Link>) becomes the row. Its own
// children become the primary text; leading / subtitle / trailing wrap
// around it. The clone/merge plumbing lives in the engine.
row = renderAsChildRow(children, cn(rowClass, hostClassName), { ref: rippleRef, ...rippleHandlers }, (anchorChildren) => (
<>
<div className="flex shrink-0 items-center empty:hidden">{leading}</div>
<div className="min-w-0 flex-1 py-2">
<div className="truncate">{anchorChildren}</div>
<div className="truncate text-sm text-[var(--color-on-surface-variant)] empty:hidden">{subtitle}</div>
</div>
{trailingCluster}
</>
));
} else {
const inner = (
<>
<div className="flex shrink-0 items-center empty:hidden">{leading}</div>
<div className="min-w-0 flex-1 py-2">
<div className="truncate">{children}</div>
<div className="truncate text-sm text-[var(--color-on-surface-variant)] empty:hidden">{subtitle}</div>
</div>
{trailingCluster}
</>
);
row = chevron ? (
<div ref={mergeRefs(rippleRef)} className={cn(rowClass, hostClassName)} {...rippleHandlers}>
{inner}
</div>
) : (
<div className="tcn-list-item-row flex items-center gap-3 px-4">{inner}</div>
);
}
return (
<div ref={mergeRefs(forwardedRef, hostRef)} className={cn('tcn-list-item block', className)} {...props}>
{row}
</div>
);
});
export interface TcnListHeaderProps extends ComponentPropsWithoutRef<'div'> {
/** Optional trailing cluster (e.g. an action button), aligned to the far end. */
trailing?: ReactNode;
}
export const TcnListHeader = forwardRef<HTMLDivElement, TcnListHeaderProps>(function TcnListHeader(
{ trailing, className, children, ...props },
ref,
) {
return (
<div ref={ref} className={cn('tcn-list-header flex items-center justify-between gap-2', className)} {...props}>
{children}
{trailing}
</div>
);
});
export type TcnListFooterProps = ComponentPropsWithoutRef<'div'>;
export const TcnListFooter = forwardRef<HTMLDivElement, TcnListFooterProps>(function TcnListFooter(
{ className, children, ...props },
ref,
) {
return (
<div ref={ref} className={cn('tcn-list-footer block', className)} {...props}>
{children}
</div>
);
});Source · Vue
<script setup lang="ts">
withDefaults(
defineProps<{
/** Built-in row hairlines. Set false to suppress them (e.g. to show dividers). */
separators?: boolean;
}>(),
{ separators: true },
);
</script>
<template>
<div :data-separators="separators ? undefined : 'false'" class="tcn-list block">
<slot />
</div>
</template><template>
<div class="tcn-list-footer block">
<slot />
</div>
</template><template>
<div class="tcn-list-header flex items-center justify-between gap-2">
<slot />
<slot name="trailing" />
</div>
</template><script setup lang="ts">
import { h, ref, useSlots } from 'vue';
import type { VNodeArrayChildren } from 'vue';
import { cn } from '@touchcn/core';
import { renderAsChildRow, useLinkedRowGuard, useRipple } from '@touchcn/vue';
const props = withDefaults(
defineProps<{
chevron?: boolean;
/**
* Link mode: render the whole row as the single slotted anchor (e.g. a
* `<RouterLink>`). The anchor's own children become the row's primary
* content; `leading` / `subtitle` / `trailing` slots wrap around it.
*/
asChild?: boolean;
}>(),
{ chevron: false, asChild: false },
);
const slots = useSlots();
const hostEl = ref<HTMLElement | null>(null);
const ripple = useRipple();
useLinkedRowGuard(hostEl, () => props.asChild);
const rowClass = 'tcn-list-item-tappable tcn-list-item-row flex items-center gap-3 px-4';
const chevronIcon = () =>
h('svg', { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true' }, [
h('path', {
d: 'M9 6l6 6-6 6',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
}),
]);
const buildInner = (primary: VNodeArrayChildren): VNodeArrayChildren => [
h('div', { class: 'flex shrink-0 items-center empty:hidden' }, slots.leading?.()),
h('div', { class: 'min-w-0 flex-1 py-2' }, [
h('div', { class: 'truncate' }, primary),
h(
'div',
{ class: 'truncate text-sm text-[var(--color-on-surface-variant)] empty:hidden' },
slots.subtitle?.(),
),
]),
h('div', { class: 'flex shrink-0 items-center gap-1.5 text-[var(--color-on-surface-variant)]' }, [
slots.trailing?.(),
props.chevron ? chevronIcon() : null,
]),
];
// Link mode: the single slotted anchor becomes the row. Its own children become
// the primary text; the clone/merge plumbing lives in the engine.
const AsChildRow = () =>
renderAsChildRow(
slots.default?.(),
cn(rowClass, ripple.hostClassName),
{
ref: ripple.setHost,
onPointerdown: ripple.onPointerdown,
onPointercancel: ripple.onPointercancel,
onClick: ripple.onClick,
},
(anchorChildren) => buildInner(anchorChildren),
);
const PlainRow = () => {
const content = buildInner(slots.default?.() ?? []);
if (props.chevron) {
return h(
'div',
{
ref: ripple.setHost,
class: cn(rowClass, ripple.hostClassName),
onPointerdown: ripple.onPointerdown,
onPointercancel: ripple.onPointercancel,
onClick: ripple.onClick,
},
content,
);
}
return h('div', { class: 'tcn-list-item-row flex items-center gap-3 px-4' }, content);
};
</script>
<template>
<div ref="hostEl" class="tcn-list-item block">
<component :is="asChild ? AsChildRow : PlainRow" />
</div>
</template>export { default as TcnList } from './TcnList.vue';
export { default as TcnListItem } from './TcnListItem.vue';
export { default as TcnListHeader } from './TcnListHeader.vue';
export { default as TcnListFooter } from './TcnListFooter.vue';