Kinetik UI

Read More

.md

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

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

Installation

Kinetik CLI
npx kinetik-ui add read-more

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/read-more.json

Install manually

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

Usage

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.

Props

ReadMoreProps

PropTypeDefaultDescription
childrenrequiredstring—The text. Paragraphs can be separated with blank lines.
linesnumber3Lines shown while folded. Defaults to 3.
moreLabelstring'Read more'Defaults to 'Read more'.
lessLabelstring'Show less'Defaults to 'Show less'.
fadeColorstring—The colour behind the text, as `#RRGGBB`; the last line fades into it. Defaults to the theme surface.
expandedboolean—Controlled state. Leave unset to let the component keep its own.
onExpandedChange(expanded: boolean) => void—
textStyleStyleProp<TextStyle>—Style for the text. Set `lineHeight` here if you change the font size.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
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.

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.

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/read-more.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 },
});