/**
* Shimmer — a highlight that sweeps *through* content rather than over it.
*
* The content is used as a mask, and a gradient band is swept behind it on the
* UI thread. Because the content is the mask, the highlight is clipped to the
* glyph shapes: text reads as though the letters themselves are catching a
* light, which is the "thinking…" treatment familiar from AI chat interfaces.
*
* ```tsx
* Thinking…
* ```
*
* Set `as="view"` to shimmer arbitrary children (skeleton blocks, cards) — the
* mask is then the rendered subtree's alpha instead of a line of text.
*/
import { useEffect, useState, type ReactNode } from 'react';
import {
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type TextStyle,
type ViewProps,
} from 'react-native';
import MaskedView from '@react-native-masked-view/masked-view';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { useCSSVariable } from 'uniwind';
import { useDirectionSign } from '../../hooks/use-direction';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
export interface ShimmerProps extends ViewProps {
className?: string;
/**
* `text` renders `children` as a single styled string and masks the sweep to
* the glyphs. `view` masks the sweep to whatever subtree you pass.
*/
as?: 'text' | 'view';
/** Milliseconds for one sweep. */
duration?: number;
/** Width of the highlight band, as a multiple of the content width. */
spread?: number;
/** Colour of the content at rest. Defaults to the theme's muted foreground. */
baseColor?: string;
/** Colour at the centre of the sweep. Defaults to the theme's foreground. */
shimmerColor?: string;
/** `loop` restarts from the left; `ping-pong` reverses on each pass. */
mode?: 'loop' | 'ping-pong';
/** Sweep once instead of repeating. */
once?: boolean;
/** Sweep right-to-left. */
reverse?: boolean;
/** Set false to render the content statically without animating. */
enabled?: boolean;
/** Extra classes for the text when `as="text"`. */
textClassName?: string;
/** Extra styles for the text when `as="text"`. */
textStyle?: StyleProp;
/**
* @deprecated Use `shimmerColor`.
*/
color?: string;
/**
* Peak opacity of the highlight.
* @deprecated Set `shimmerColor` to a colour with the alpha you want.
*/
intensity?: number;
children?: ReactNode;
}
export function Shimmer({
className,
as = 'text',
duration = 2000,
spread = 2,
baseColor,
shimmerColor,
mode = 'loop',
once = false,
reverse = false,
enabled = true,
textClassName,
textStyle,
color,
intensity,
children,
...props
}: ShimmerProps) {
const [width, setWidth] = useState(0);
const progress = useSharedValue(0);
const reducedMotion = useReducedMotion();
// The sweep follows the reading direction; `reverse` then flips it again.
const sign = useDirectionSign();
const themeMuted = useCSSVariable('--color-muted-foreground');
const themeForeground = useCSSVariable('--color-foreground');
const base =
baseColor ?? (typeof themeMuted === 'string' ? themeMuted : '#a1a1aa');
const highlight =
shimmerColor ??
color ??
(typeof themeForeground === 'string' ? themeForeground : '#ffffff');
// The band is wider than the content so the highlight ramps in and out
// instead of appearing at full strength against the edge.
const bandWidth = Math.max(width * spread, 1);
const animating = enabled && !reducedMotion && width > 0;
useEffect(() => {
if (!animating) {
cancelAnimation(progress);
progress.value = 0;
return;
}
progress.value = 0;
progress.value = withRepeat(
withTiming(1, { duration, easing: Easing.linear }),
once ? 1 : -1,
mode === 'ping-pong'
);
return () => cancelAnimation(progress);
}, [animating, duration, mode, once, progress]);
const sweepStyle = useAnimatedStyle(() => {
// Travel from fully off one edge to fully off the other.
const from = -bandWidth;
const to = width;
/*
* The sweep follows the reading direction. It is standing in for text
* arriving, and text arrives from the side you start reading on — a band
* travelling against the script reads as something leaving.
*/
const backwards = reverse !== (sign === -1);
const x = backwards
? to - progress.value * (to - from)
: from + progress.value * (to - from);
return { transform: [{ translateX: x }] };
});
const onLayout = (event: LayoutChangeEvent) => {
setWidth(event.nativeEvent.layout.width);
props.onLayout?.(event);
};
const content =
as === 'text' ? (
{children}
) : (
children
);
// Without a measured width there is nothing to sweep across, and masking an
// unmeasured subtree just costs a layer — render the content plainly.
if (!animating) {
return (
{content}
);
}
const highlightWithAlpha =
intensity !== undefined ? withAlpha(highlight, intensity) : highlight;
return (
{content}}>
{/* The mask only exposes the glyphs, so this layer is what they are
painted with: flat base colour, plus a moving gradient band. */}
{content}
);
}
Shimmer.displayName = 'Shimmer';
/** Applies an alpha to a `#rgb`/`#rrggbb`/`rgb()` colour. Supports `intensity`. */
function withAlpha(color: string, alpha: number): string {
const clamped = Math.max(0, Math.min(1, alpha));
if (color.startsWith('#')) {
const hex = color.slice(1);
const full =
hex.length === 3
? hex
.split('')
.map((c) => c + c)
.join('')
: hex.slice(0, 6);
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
if (Number.isNaN(r + g + b)) return color;
return `rgba(${r}, ${g}, ${b}, ${clamped})`;
}
const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
if (channels) {
const [r, g, b] = channels.split(',').map((part) => part.trim());
return `rgba(${r}, ${g}, ${b}, ${clamped})`;
}
return color;
}