Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

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>

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';

Last updated on July 24, 2026

Was this page helpful?