Kinetik UI

Tree View

.md

Nested folders whose branches grow open to their measured height as the chevron turns and rows drift in.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add tree-view

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/tree-view.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/tree-view.tsx
    View source files

Usage

import { TreeView } from '@/components/kinetik/tree-view';

<TreeView
  data={[
    {
      id: 'app',
      label: 'app',
      children: [{ id: 'home', label: 'index.tsx', detail: '4 KB' }],
    },
  ]}
  defaultExpanded={['app']}
  selected={current}
  onSelect={(node) => setCurrent(node.id)}
/>;

When to use

  • Hierarchies people browse a level at a time: folders, categories, an outline, settings groups.
  • Places where seeing the path to an item helps, not just the item.

When not to use

  • Flat lists. Use plain rows.
  • Deep trees on a phone (more than three or four levels). Drill into a new screen per level instead.

Notes

  • A branch grows to its measured height while its rows drift in; nested branches resize their parents as they open.
  • Children are built the first time a branch opens and kept afterwards, so large trees stay cheap.
  • Every row is a button; branches say whether they are expanded and the selected row says it is selected.
  • With reduced motion branches open and close at once.

Props

TreeViewProps

PropTypeDefaultDescription
datarequiredreadonly TreeNode[]—
defaultExpandedreadonly string[][]Branches open at first.
selectedstring—The highlighted node.
onSelect(node: TreeNode) => void—Called for a press on any node; branches also open or close.
hapticsbooleantrueA tick when a branch opens or closes. Defaults to true.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Plain views; each open branch springs one height value. Children are built on first open. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 152 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
Branches open and close at once.
Screen readers
Every row is a button with its label and detail; branches report expanded or collapsed, and closed branches are hidden.
Touch target
Rows are 44 pt tall and span the full width.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/tree-view.tsx
import { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type TreeNode = {
  id: string;
  label: string;
  /** A short note on the right, e.g. a size or a count. */
  detail?: string;
  children?: readonly TreeNode[];
};

export type TreeViewProps = {
  data: readonly TreeNode[];
  /** Branches open at first. */
  defaultExpanded?: readonly string[];
  /** The highlighted node. */
  selected?: string;
  /** Called for a press on any node; branches also open or close. */
  onSelect?: (node: TreeNode) => void;
  /** A tick when a branch opens or closes. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const ROW = 44;
const INDENT = 20;

/**
 * Nested folders and items. A branch opens by growing to its measured height
 * while its rows drift in, the chevron turns a quarter, and a thin guide line
 * marks each level. Children are only built the first time a branch opens.
 */
export function TreeView({
  data,
  defaultExpanded = [],
  selected,
  onSelect,
  haptics = true,
  testID,
  style,
}: TreeViewProps) {
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set(defaultExpanded));

  const toggle = (node: TreeNode) => {
    if (node.children?.length) {
      haptic('tick');
      setOpen((prev) => {
        const next = new Set(prev);
        if (next.has(node.id)) next.delete(node.id);
        else next.add(node.id);
        return next;
      });
    }
    onSelect?.(node);
  };

  return (
    <View testID={testID} style={style}>
      {data.map((node) => (
        <Branch
          key={node.id}
          node={node}
          depth={0}
          open={open}
          selected={selected}
          reduced={reduced}
          onPress={toggle}
        />
      ))}
    </View>
  );
}

type BranchProps = {
  node: TreeNode;
  depth: number;
  open: ReadonlySet<string>;
  selected: string | undefined;
  reduced: boolean;
  onPress: (node: TreeNode) => void;
};

function Branch({ node, depth, open, selected, reduced, onPress }: BranchProps) {
  const { colors, font, radius } = useKinetikTheme();
  const kids = node.children ?? [];
  const parent = kids.length > 0;
  const expanded = parent && open.has(node.id);
  // Children are built the first time the branch opens, then kept for closing.
  const [built, setBuilt] = useState(expanded);
  if (expanded && !built) setBuilt(true);
  const [measured, setMeasured] = useState(0);

  const turn = useSharedValue(expanded ? 1 : 0);
  const height = useSharedValue(0);
  // The first known height is applied at once; later changes spring.
  const placed = useRef(false);
  useEffect(() => {
    turn.set(withSpring(expanded ? 1 : 0, spring('snappy', reduced)));
  }, [expanded, reduced, turn]);
  useEffect(() => {
    if (measured === 0) return;
    const target = expanded ? measured : 0;
    height.set(!placed.current || reduced ? target : withSpring(target, spring('smooth')));
    placed.current = true;
  }, [expanded, measured, reduced, height]);

  const box = useAnimatedStyle(() => ({ height: height.get() }));
  const fade = useAnimatedStyle(() => ({
    opacity: withTiming(expanded ? 1 : 0, timing('quick', reduced)),
    transform: [{ translateY: withSpring(expanded ? 0 : -6, spring('smooth', reduced)) }],
  }));
  const chevron = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.get() * 90}deg` }] }));
  const isSelected = selected === node.id;

  return (
    <View>
      <Pressable
        onPress={() => onPress(node)}
        accessibilityRole="button"
        accessibilityLabel={[node.label, node.detail].filter(Boolean).join(', ')}
        accessibilityState={{
          expanded: parent ? expanded : undefined,
          selected: isSelected,
        }}
        style={({ pressed }) => [
          styles.row,
          {
            paddingLeft: 8 + depth * INDENT,
            borderRadius: radius.sm,
            backgroundColor: isSelected
              ? colors.azureSoft
              : pressed
                ? colors.surfaceSunken
                : undefined,
          },
        ]}
      >
        <View style={styles.glyph}>
          {parent ? (
            <Animated.View style={[styles.chevron, chevron]}>
              <View style={[styles.stroke, styles.up, { backgroundColor: colors.textMuted }]} />
              <View style={[styles.stroke, styles.down, { backgroundColor: colors.textMuted }]} />
            </Animated.View>
          ) : (
            <View style={[styles.leaf, { backgroundColor: colors.textSubtle }]} />
          )}
        </View>
        <Text
          numberOfLines={1}
          style={[
            styles.label,
            {
              color: isSelected ? colors.azure : colors.text,
              fontSize: font.size.md,
              fontWeight: parent ? font.weight.medium : font.weight.regular,
            },
          ]}
        >
          {node.label}
        </Text>
        {node.detail ? (
          <Text
            style={{
              color: colors.textSubtle,
              fontSize: font.size.xs,
              fontVariant: ['tabular-nums'],
            }}
          >
            {node.detail}
          </Text>
        ) : null}
      </Pressable>
      {parent && built ? (
        <Animated.View
          style={[styles.box, box]}
          accessibilityElementsHidden={!expanded}
          importantForAccessibility={expanded ? 'auto' : 'no-hide-descendants'}
        >
          {/* Laid out on its own, so a closed (zero-height) box never squeezes it before it is measured. */}
          <Animated.View
            style={[styles.content, fade]}
            onLayout={(e) => {
              const h = Math.round(e.nativeEvent.layout.height);
              if (h !== measured) setMeasured(h);
            }}
          >
            <View
              pointerEvents="none"
              style={[
                styles.guide,
                { left: 8 + depth * INDENT + 9, backgroundColor: colors.border },
              ]}
            />
            {kids.map((child) => (
              <Branch
                key={child.id}
                node={child}
                depth={depth + 1}
                open={open}
                selected={selected}
                reduced={reduced}
                onPress={onPress}
              />
            ))}
          </Animated.View>
        </Animated.View>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  row: { height: ROW, flexDirection: 'row', alignItems: 'center', gap: 8, paddingRight: 12 },
  glyph: { width: 18, height: 18, alignItems: 'center', justifyContent: 'center' },
  chevron: { width: 14, height: 14 },
  stroke: { position: 'absolute', width: 7, height: 1.8, borderRadius: 1, left: 4.5 },
  up: { top: 4.6, transform: [{ rotate: '45deg' }] },
  down: { top: 8.6, transform: [{ rotate: '-45deg' }] },
  leaf: { width: 5, height: 5, borderRadius: 2.5 },
  label: { flex: 1 },
  box: { overflow: 'hidden' },
  content: { position: 'absolute', top: 0, left: 0, right: 0 },
  guide: { position: 'absolute', top: 4, bottom: 4, width: StyleSheet.hairlineWidth * 2 },
});