Read More
Long text folded to a few lines that fade out; Read more lets it out to full height as the fade dissolves.
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 },
});Installation
npx kinetik-ui add read-morepnpm dlx kinetik-ui add read-morebunx kinetik-ui add read-moreyarn dlx kinetik-ui add read-moreThe 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
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/read-more.tsx
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
fontSizewithtextStyleand set itslineHeighttoo, 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
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | 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> | — |
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 },
});