# List

> Settings-style rows: ListSection, ListSectionHeader, ListSectionFooter and ListItem (title, description, icon, trailing, chevron, destructive, onPress); FlashList-safe.

Component · display. Registry item `list`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/list.json

## Installation

```sh
npx nativecn-cli@latest add list
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [pressable](https://nativecn.dev/docs/primitives/pressable), [text](https://nativecn.dev/docs/components/text), [icon](https://nativecn.dev/docs/components/icon), [separator](https://nativecn.dev/docs/components/separator).

Packages: `lucide-react-native@^1.51.0`, `react-native-svg`.

## Usage

### List demo

A settings list: header, footer, icons, trailing values, chevrons and a destructive row.

```tsx
import { Bell, LogOut, Moon, User } from "lucide-react-native";
import { View } from "react-native";

import { Badge } from "@/components/badge";
import {
  ListItem,
  ListSection,
  ListSectionFooter,
  ListSectionHeader,
} from "@/components/list";
import { createStyles } from "@/theme";

export default function ListDemo() {
  const styles = useStyles();
  return (
    <View style={styles.stack}>
      <ListSection>
        <ListSectionHeader>Account</ListSectionHeader>
        <ListItem
          icon={User}
          title="Profile"
          description="Name, photo and email"
          chevron
          onPress={() => {}}
        />
        <ListItem
          icon={Bell}
          title="Notifications"
          trailing={<Badge label="3" />}
          chevron
          onPress={() => {}}
        />
        <ListItem icon={Moon} title="Appearance" trailing="System" chevron onPress={() => {}} />
        <ListSectionFooter>Changes sync across your devices.</ListSectionFooter>
      </ListSection>

      <ListSection>
        <ListItem icon={LogOut} title="Sign out" destructive onPress={() => {}} />
      </ListSection>
    </View>
  );
}

// In a FlashList, render ListItem as the row and Separator as ItemSeparatorComponent:
// <FlashList data={rows} renderItem={({ item }) => <ListItem title={item.name} chevron onPress={...} />} />

const useStyles = createStyles((t) => ({
  stack: { gap: t.spacing[6] },
}));
```

## Props

### ListSection

| Prop | Type and description |
|---|---|
| `children` | ListSectionHeader, rows and ListSectionFooter; header and footer are drawn outside the rounded group |

### ListSectionHeader

| Prop | Type and description |
|---|---|
| `children` | the section title (role heading) |

### ListSectionFooter

| Prop | Type and description |
|---|---|
| `children` | a note under the rows |

### ListItem

| Prop | Type and description |
|---|---|
| `title` | string (required unless children) |
| `description` | string: muted text under the title |
| `icon` | a Lucide icon component, at the start of the row |
| `trailing` | ReactNode at the end of the row (strings render as muted text): a value, Badge, Switch… |
| `chevron` | boolean (default false): a trailing chevron for rows that navigate |
| `destructive` | boolean (default false): title and icon in the destructive colour |
| `onPress` | (event) => void: makes the row pressable (role button) |
| `disabled` | boolean (default false): pressable rows only |
| `children` | escape hatch replacing the title and description column |
| `style` | layout only, merged last onto the root |

## Accessibility

- ListSection has role list and ListSectionHeader role heading.
- A row with onPress or onLongPress has role button, named by its text, with its tap area extended to 48; other rows have role listitem.
- disabled is announced as disabled on a pressable row and blocks presses.
- Row icons, the chevron and the Separators between rows are decorative.

## Notes

Props-based rows are a deliberate exception to 'children over content props'; use children only for custom rows. ListSection draws an inset, rounded group with hairline Separators between rows. For long lists, render ListItem directly as a FlashList/FlatList row (no ListSection) with Separator as ItemSeparatorComponent: ListItem has no state, no layout measuring and no parent context, so recycling is safe.

## Source

### {components}/list.tsx

```tsx
import { ChevronRight, type LucideIcon } from "lucide-react-native";
import {
  Children,
  Fragment,
  isValidElement,
  type ReactElement,
  type ReactNode,
  type Ref,
} from "react";
import {
  View,
  type GestureResponderEvent,
  type StyleProp,
  type ViewProps,
  type ViewStyle,
} from "react-native";

import { Icon } from "@/components/icon";
import {
  Pressable,
  touchTargetHitSlop,
  type PressableHaptic,
} from "@/components/primitives/pressable";
import { Separator } from "@/components/separator";
import { Text, type TextProps } from "@/components/text";
import { createStyles, useTheme } from "@/theme";

export type ListSectionProps = ViewProps & {
  /** ListSectionHeader, ListItems (or any rows) and ListSectionFooter, in any order. */
  children?: ReactNode;
  ref?: Ref<View>;
};

export type ListSectionHeaderProps = Omit<TextProps, "variant">;
export type ListSectionFooterProps = Omit<TextProps, "variant">;

type ListItemBaseProps = Omit<ViewProps, "style" | "hitSlop" | "children"> & {
  /** Supporting text under the title, in the muted foreground. */
  description?: string;
  /** A Lucide icon at the start of the row. */
  icon?: LucideIcon;
  /** Anything at the end of the row: a value (strings are muted Text), a Badge, a Switch… */
  trailing?: ReactNode;
  /** A trailing chevron, for rows that navigate. */
  chevron?: boolean;
  /** Draws the title and icon in the `destructive` Colour Role (e.g. "Delete account"). */
  destructive?: boolean;
  /** Makes the row pressable (role `button`, pressed look from the `list.pressed` Slot). */
  onPress?: (event: GestureResponderEvent) => void;
  onLongPress?: (event: GestureResponderEvent) => void;
  /** Only for a pressable row: blocks presses, dims it and announces it as disabled. */
  disabled?: boolean;
  /** Only for a pressable row: haptic feedback on press (when `config.haptics` is on). */
  haptic?: PressableHaptic;
  /** Layout only, merged last onto the root. */
  style?: StyleProp<ViewStyle>;
  ref?: Ref<View>;
};

/**
 * A row is props-based (`title`, `description`, …), a deliberate exception to "children over
 * content props". `children` is the escape hatch: it replaces the title and description column.
 */
export type ListItemProps = ListItemBaseProps &
  ({ title: string; children?: undefined } | { title?: string; children: ReactNode });

const useStyles = createStyles((t) => {
  const item = {
    minHeight: t.scaleValue(52),
    paddingHorizontal: t.spacing[4],
    gap: t.spacing[3],
  };
  const section = { borderRadius: t.radius.xl, marginHorizontal: t.spacing[4] };
  return {
    section: { marginHorizontal: section.marginHorizontal, gap: t.spacing[2] },
    group: {
      borderRadius: section.borderRadius,
      borderCurve: "continuous",
      backgroundColor: t.colors.card,
      overflow: "hidden",
    },
    header: { paddingHorizontal: item.paddingHorizontal },
    footer: { paddingHorizontal: item.paddingHorizontal },
    // Inset by the row padding, iOS style.
    separator: { marginStart: item.paddingHorizontal },
    item: {
      ...item,
      flexDirection: "row",
      alignItems: "center",
      paddingVertical: t.spacing[2],
      backgroundColor: t.colors.card,
    },
    pressed: { backgroundColor: t.colors.muted },
    disabled: { opacity: t.opacity.disabled },
    body: { flex: 1, minWidth: 0 },
    title: { ...t.type.body },
    trailing: { flexDirection: "row", alignItems: "center", gap: t.spacing[2], flexShrink: 0 },
  };
});

const isElementOf = (node: ReactNode, type: unknown): node is ReactElement =>
  isValidElement(node) && node.type === type;

/**
 * A grouped, rounded block of rows (iOS inset-grouped style). Rows are divided by hairline
 * Separators; a ListSectionHeader and ListSectionFooter, wherever they appear among the
 * children, are drawn above and below the group. For long or virtualized lists, render
 * ListItems directly as FlashList / FlatList rows instead: they need no ListSection.
 * `style` is merged last onto the root.
 */
export function ListSection({ children, style, ...props }: ListSectionProps) {
  const styles = useStyles();
  const all = Children.toArray(children);
  const headers = all.filter((c) => isElementOf(c, ListSectionHeader));
  const footers = all.filter((c) => isElementOf(c, ListSectionFooter));
  const rows = all.filter(
    (c) => !isElementOf(c, ListSectionHeader) && !isElementOf(c, ListSectionFooter),
  );

  return (
    <View role="list" style={[styles.section, style]} {...props}>
      {headers}
      {rows.length ? (
        <View style={styles.group}>
          {rows.map((row, i) => (
            <Fragment key={isValidElement(row) && row.key != null ? row.key : i}>
              {i > 0 ? <Separator style={styles.separator} /> : null}
              {row}
            </Fragment>
          ))}
        </View>
      ) : null}
      {footers}
    </View>
  );
}

/** The heading above a ListSection's rows (role `heading`). */
export function ListSectionHeader({ style, ...props }: ListSectionHeaderProps) {
  const styles = useStyles();
  return (
    <Text
      role="heading"
      variant="small"
      color="mutedForeground"
      style={[styles.header, style]}
      {...props}
    />
  );
}

/** A note below a ListSection's rows. */
export function ListSectionFooter({ style, ...props }: ListSectionFooterProps) {
  const styles = useStyles();
  return (
    <Text variant="caption" color="mutedForeground" style={[styles.footer, style]} {...props} />
  );
}

/**
 * One row: icon, title and description, then `trailing` and a chevron. With `onPress` the row is
 * pressable (built on the Pressable Primitive, tap area extended to 48). FlashList-safe: no
 * state, no layout measuring, no parent context and no outer margins, so it can be a
 * virtualized list's row as-is. `style` is merged last onto the root.
 */
export function ListItem({
  title,
  description,
  icon,
  trailing,
  chevron = false,
  destructive = false,
  onPress,
  onLongPress,
  disabled = false,
  haptic,
  children,
  style,
  ...props
}: ListItemProps) {
  const styles = useStyles();
  const { minTouchTarget } = useTheme();
  const tone = destructive ? "destructive" : "foreground";

  const content = (
    <>
      {icon ? <Icon icon={icon} color={tone} /> : null}
      <View style={styles.body}>
        {children ?? (
          <>
            <Text color={tone} style={styles.title}>
              {title}
            </Text>
            {description ? (
              <Text variant="small" color="mutedForeground">
                {description}
              </Text>
            ) : null}
          </>
        )}
      </View>
      {trailing != null || chevron ? (
        <View style={styles.trailing}>
          {typeof trailing === "string" || typeof trailing === "number" ? (
            <Text variant="small" color="mutedForeground" numberOfLines={1}>
              {trailing}
            </Text>
          ) : (
            trailing
          )}
          {chevron ? <Icon icon={ChevronRight} size="sm" color="mutedForeground" /> : null}
        </View>
      ) : null}
    </>
  );

  if (onPress || onLongPress) {
    // Known from the Slot, so the tap area reaches 48 without a layout pass (recycled rows).
    const hitSlop = touchTargetHitSlop(undefined, styles.item.minHeight as number, minTouchTarget);
    return (
      <Pressable
        onPress={onPress}
        onLongPress={onLongPress}
        disabled={disabled}
        haptic={haptic}
        hitSlop={hitSlop ?? 0}
        pressedStyle={styles.pressed}
        style={[styles.item, disabled ? styles.disabled : null, style]}
        {...props}
      >
        {content}
      </Pressable>
    );
  }

  return (
    <View role="listitem" style={[styles.item, style]} {...props}>
      {content}
    </View>
  );
}
```
