/**
* ScrollText — a line of text that resolves word by word as you scroll past it.
*
* The reader's thumb becomes the playhead. Words arrive left to right as the
* block crosses the viewport, and going back up unresolves them — the effect is
* scrubbed, not triggered, so it is reversible and lands exactly on both ends.
*
* ```tsx
*
*
*
* Every control ships with its accessibility wiring already done.
*
*
*
* ```
*
* ## Two layouts, and why
*
* `color`, `fade` and `highlight` render the words as nested `Text` inside one
* parent, which is the only way React Native will break them into real lines —
* hyphenation, justification, the lot. Nested text cannot be transformed,
* though: a `translateY` on it is ignored.
*
* So `rise` lays the words out as a wrapping row of separate views instead.
* That buys transforms and costs real line-breaking — words wrap on their own
* boundaries and the spacing is a margin rather than a space. Worth knowing
* before picking the effect for a paragraph rather than a heading.
*/
import { useMemo } from 'react';
import { Text as RNText, View, type TextStyle } from 'react-native';
import Animated, {
interpolate,
interpolateColor,
useAnimatedStyle,
useReducedMotion,
type SharedValue,
} from 'react-native-reanimated';
import { useCSSVariable } from 'uniwind';
import { useRevealProgress } from '../../hooks/use-reveal-progress';
import { Text, type TextProps } from '../../primitives/text';
const AnimatedText = Animated.createAnimatedComponent(RNText);
/** How far a word rises from, in pixels, for the `rise` effect. */
const RISE_DISTANCE = 18;
export type ScrollTextEffect = 'color' | 'fade' | 'rise' | 'highlight';
export type ScrollTextSplit = 'word' | 'character';
/** Effects that need a transform, and so cannot use nested text. */
const NEEDS_ROW: ScrollTextEffect[] = ['rise'];
export interface ScrollTextProps extends Omit {
className?: string;
/** The sentence. Split into words or characters, then revealed across them. */
children?: string;
/**
* `color` crossfades each word between two colours, `fade` brings it up from
* transparent, `rise` lifts it into place, `highlight` sweeps a background
* behind it.
*/
effect?: ScrollTextEffect;
/** Reveal a word at a time, or a character at a time. */
by?: ScrollTextSplit;
/** Colour before a word is reached. Defaults to the muted foreground token. */
from?: string;
/** Colour once it has been. Defaults to the foreground token. */
to?: string;
/**
* Where down the viewport the block's top sits when the reveal starts, as a
* fraction of the viewport height.
*/
start?: number;
/** Where its bottom sits when the reveal completes. Smaller is a longer scrub. */
end?: number;
/**
* How much of the whole reveal a single word takes, `0` to `1`. Small values
* make a hard edge travelling along the line; large ones make the whole
* sentence brighten together.
*/
stagger?: number;
/**
* Drive the reveal from a value of your own rather than from scroll — a
* progress bar, a gesture, a timeline.
*/
progress?: SharedValue;
/** Set false to render the text resolved, with no effect at all. */
enabled?: boolean;
}
export function ScrollText({
className,
children = '',
effect = 'color',
by = 'word',
from,
to,
start = 0.9,
end = 0.5,
stagger = 0.35,
progress: external,
enabled = true,
...textProps
}: ScrollTextProps) {
const reducedMotion = useReducedMotion();
const active = enabled && !reducedMotion;
const { ref, onLayout, progress } = useRevealProgress({
start,
end,
progress: external,
enabled: active,
});
const mutedToken = useCSSVariable('--color-muted-foreground');
const foregroundToken = useCSSVariable('--color-foreground');
const accentToken = useCSSVariable('--color-accent');
const dim = from ?? (typeof mutedToken === 'string' ? mutedToken : '#a1a1aa');
const lit = to ?? (typeof foregroundToken === 'string' ? foregroundToken : '#0a0a0a');
const highlight = typeof accentToken === 'string' ? accentToken : 'rgba(0,0,0,0.08)';
const tokens = useMemo(() => splitText(children, by), [children, by]);
// Every token gets the same slice of the reveal, offset along the line. The
// slice is a fraction of the whole rather than a fixed duration, so a long
// sentence and a short one both finish exactly when the block has passed.
const span = Math.max(stagger, 0.01);
const stride = tokens.length > 1 ? (1 - span) / (tokens.length - 1) : 0;
const asRow = NEEDS_ROW.includes(effect);
if (asRow) {
return (
{tokens.map((token, index) => (
))}
);
}
return (
{tokens.map((token, index) => (
))}
);
}
ScrollText.displayName = 'ScrollText';
/**
* One word or character inside the parent's text flow, inked by the reveal.
*
* Nested text, so React Native breaks the sentence into real lines. That rules
* out transforms — colour, opacity and background are what nested text will
* animate, which is exactly the three effects routed here.
*/
function InkedToken({
text,
progress,
startAt,
span,
effect,
dim,
lit,
highlight,
last,
}: {
text: string;
progress: SharedValue;
startAt: number;
span: number;
effect: ScrollTextEffect;
dim: string;
lit: string;
highlight: string;
last: boolean;
}) {
const style = useAnimatedStyle(() => {
const local = Math.min(1, Math.max(0, (progress.value - startAt) / span));
if (effect === 'fade') {
// Not all the way to transparent: a word that vanishes takes the line's
// shape with it, and the paragraph reflows as you scroll.
return { opacity: interpolate(local, [0, 1], [0.18, 1]), color: lit } as TextStyle;
}
if (effect === 'highlight') {
return {
color: interpolateColor(local, [0, 1], [dim, lit]),
backgroundColor: interpolateColor(local, [0, 1], ['transparent', highlight]),
} as TextStyle;
}
return { color: interpolateColor(local, [0, 1], [dim, lit]) } as TextStyle;
});
return {last ? text : `${text} `};
}
/**
* One word as its own box, lifted into place.
*
* A transform needs a view, and a view in a text flow is a word that no longer
* takes part in line-breaking — hence the wrapping row above, and hence the
* margin below, since a trailing space inside a box is not a space between
* boxes.
*/
function RisingToken({
text,
progress,
startAt,
span,
last,
textProps,
className,
}: {
text: string;
progress: SharedValue;
startAt: number;
span: number;
last: boolean;
textProps: Omit;
className?: string;
}) {
const style = useAnimatedStyle(() => {
const local = Math.min(1, Math.max(0, (progress.value - startAt) / span));
return {
opacity: local,
transform: [{ translateY: interpolate(local, [0, 1], [RISE_DISTANCE, 0]) }],
};
});
return (
{text}
);
}
/**
* Splits on whitespace, keeping punctuation attached to the word it belongs to.
* Splitting a sentence into bare words and re-joining with single spaces loses
* the double space after a full stop and every non-breaking space in it.
*/
function splitText(text: string, by: ScrollTextSplit): string[] {
const trimmed = text.trim();
if (!trimmed) return [];
if (by === 'character') return Array.from(trimmed);
return trimmed.split(/\s+/);
}