# Read More

> Long text folded to a few lines that fade out; Read more lets it out to full height as the fade dissolves.

- Category: Disclosure
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add read-more
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/read-more.json
```

## Usage

```tsx
import { ReadMore } from '@/components/kinetik/read-more';

<ReadMore lines={3}>{listing.description}</ReadMore>;
```

The last visible line fades into `fadeColor`, which defaults to the theme surface. Pass the colour behind the text, as `#RRGGBB`, when it sits on something else.

## When to use

- Descriptions, reviews and bios that are worth reading but should not push the rest of the screen down.
- Lists of items where each has a long note.

## When not to use

- Text people must read, such as terms they agree to. Show it in full.
- A single sentence. Folding saves nothing.

## Notes

- The toggle appears only when the text is longer than `lines`. Short text renders as it is.
- Change `fontSize` with `textStyle` and set its `lineHeight` too, so the fold lands on a whole line.
- Screen readers always get the whole text; the toggle is still offered so the screen matches what is read.
- With reduced motion the text opens and folds at once.

## ReadMoreProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `string` |  | The text. Paragraphs can be separated with blank lines. |
| `lines`? | `number` | `3` | Lines shown while folded. Defaults to 3. |
| `moreLabel`? | `string` | `'Read more'` | Defaults to 'Read more'. |
| `lessLabel`? | `string` | `'Show less'` | Defaults to 'Show less'. |
| `fadeColor`? | `string` |  | The colour behind the text, as `#RRGGBB`; the last line fades into it. Defaults to the theme surface. |
| `expanded`? | `boolean` |  | Controlled state. Leave unset to let the component keep its own. |
| `onExpandedChange`? | `(expanded: boolean) => void` |  |  |
| `textStyle`? | `StyleProp<TextStyle>` |  | Style for the text. Set `lineHeight` here if you change the font size. |
| `testID`? | `string` |  |  |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The text opens and folds at once.
- Screen readers: The whole text is always read, folded or not. The toggle is a button with an expanded state.
- Touch target: The toggle is at least 44 pt tall.

## Performance

One text view and one shared value. The box's height animates, so content below it moves for the length of the spring; nothing runs at rest.

- Measured: Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 142 MB app memory
- Real-device measurement pending.

## Source

`components/kinetik/read-more.tsx`

```tsx
import { useEffect, useState } from 'react';
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type ReadMoreProps = {
  /** The text. Paragraphs can be separated with blank lines. */
  children: string;
  /** Lines shown while folded. Defaults to 3. */
  lines?: number;
  /** Defaults to 'Read more'. */
  moreLabel?: string;
  /** Defaults to 'Show less'. */
  lessLabel?: string;
  /** The colour behind the text, as `#RRGGBB`; the last line fades into it. Defaults to the theme surface. */
  fadeColor?: string;
  /** Controlled state. Leave unset to let the component keep its own. */
  expanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
  /** Style for the text. Set `lineHeight` here if you change the font size. */
  textStyle?: StyleProp<TextStyle>;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

/**
 * Long text folded to a few lines whose last line fades away. Read more
 * lets the text out to its full height on a spring while the fade dissolves;
 * Show less folds it back.
 */
export function ReadMore({
  children,
  lines = 3,
  moreLabel = 'Read more',
  lessLabel = 'Show less',
  fadeColor,
  expanded,
  onExpandedChange,
  textStyle,
  testID,
  style,
}: ReadMoreProps) {
  const { colors, font, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [inner, setInner] = useState(false);
  const isOpen = expanded ?? inner;
  const [full, setFull] = useState(0);

  const flat = StyleSheet.flatten([
    {
      color: colors.textMuted,
      fontSize: font.size.md,
      lineHeight: Math.round(font.size.md * 1.55),
    },
    textStyle,
  ]);
  const lineHeight = flat.lineHeight ?? Math.round((flat.fontSize ?? font.size.md) * 1.55);
  const folded = lines * lineHeight;
  // Only offer the toggle when there is more to read.
  const long = full > folded + 1;

  const t = useSharedValue(isOpen ? 1 : 0);
  useEffect(() => {
    t.set(withSpring(isOpen ? 1 : 0, spring('smooth', reduced)));
  }, [isOpen, reduced, t]);

  const toggle = () => {
    const next = !isOpen;
    if (expanded === undefined) setInner(next);
    onExpandedChange?.(next);
  };

  // The text is laid out on its own (absolutely), so it is measured at its full height
  // whatever the box's height is; the box alone does the folding.
  const box = useAnimatedStyle(() => ({
    height: full === 0 ? folded : long ? folded + (full - folded) * Math.max(0, t.get()) : full,
  }));
  const fade = useAnimatedStyle(() => ({ opacity: 1 - Math.min(1, Math.max(0, t.get()) * 1.5) }));
  const chevron = useAnimatedStyle(() => ({
    transform: [{ rotate: `${45 + t.get() * 180}deg` }, { translateY: t.get() * 2 - 2 }],
  }));

  const ground = fadeColor ?? colors.surface;

  return (
    <View testID={testID} style={style}>
      <Animated.View style={[styles.box, box]}>
        <Text
          style={[flat, styles.text]}
          onLayout={(e) => setFull(Math.ceil(e.nativeEvent.layout.height))}
        >
          {children}
        </Text>
        {long ? (
          <Animated.View
            pointerEvents="none"
            style={[
              styles.fade,
              { height: lineHeight * 1.6 },
              gradient(`linear-gradient(to bottom, ${withAlpha(ground, 0)}, ${ground})`),
              fade,
            ]}
          />
        ) : null}
      </Animated.View>
      {long ? (
        <Pressable
          onPress={toggle}
          accessibilityRole="button"
          accessibilityState={{ expanded: isOpen }}
          style={({ pressed }) => [styles.toggle, { gap: space[2], opacity: pressed ? 0.6 : 1 }]}
        >
          <Text
            style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.semibold }}
          >
            {isOpen ? lessLabel : moreLabel}
          </Text>
          <Animated.View style={[styles.chevron, { borderColor: colors.text }, chevron]} />
        </Pressable>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  box: { overflow: 'hidden' },
  text: { position: 'absolute', top: 0, left: 0, right: 0 },
  fade: { position: 'absolute', left: 0, right: 0, bottom: 0 },
  toggle: {
    flexDirection: 'row',
    alignItems: 'center',
    alignSelf: 'flex-start',
    minHeight: minTouchTarget,
  },
  // The bottom and right edges of a small square, turned to point down.
  chevron: { width: 7, height: 7, borderRightWidth: 1.5, borderBottomWidth: 1.5 },
});
```
