List
Settings-style rows: ListSection, ListSectionHeader, ListSectionFooter and ListItem (title, description, icon, trailing, chevron, destructive, onPress); FlashList-safe.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add listAlso installs: theme, pressable, text, icon, 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.
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] },
}));List demo
A settings list: header, footer, icons, trailing values, chevrons and a destructive row.
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
The Vega Style's code, exactly as add copies it into your app.
{components}/list.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>
);
}{components}/list.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(44),
paddingHorizontal: t.spacing[3],
gap: t.scaleValue(10),
};
const section = { borderRadius: t.radius.lg, marginHorizontal: t.spacing[3] };
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,
fontSize: t.scaleValue(15),
lineHeight: t.scaleValue(20),
},
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>
);
}
