Shine Text
A line of text with a band of light sweeping across the glyphs.
import {
Canvas,
LinearGradient,
Text as SkiaText,
matchFont,
vec,
} from '@shopify/react-native-skia';
import { useEffect, useMemo } from 'react';
import {
Platform,
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import {
Easing,
cancelAnimation,
useDerivedValue,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ShineTextProps = {
/** Single line of text. */
text: string;
/** Font size in pt. Defaults to 36. */
fontSize?: number;
/** Defaults to '800'. */
fontWeight?: TextStyle['fontWeight'];
/** Font family. Defaults to the system sans-serif. */
fontFamily?: string;
/** Base text colour. Defaults to the theme textMuted. */
color?: string;
/** Colour of the sheen. Defaults to the theme highlight. */
shineColor?: string;
/** Time for one sweep, in ms. Defaults to 1800. */
duration?: number;
/** Rest between sweeps, in ms. Defaults to 1400. */
pause?: number;
/** Stop sweeping, e.g. while off-screen. Defaults to true. */
active?: boolean;
style?: StyleProp<ViewStyle>;
};
const SYSTEM_FAMILY = Platform.select({ ios: 'Helvetica Neue', default: 'sans-serif' });
/**
* A single line of text with a band of light sweeping across it. The sheen
* is a real gradient masked by the glyphs: Skia on iOS and Android, CSS
* background-clip on the web.
*/
export function ShineText(props: ShineTextProps) {
return Platform.OS === 'web' ? <ShineTextWeb {...props} /> : <ShineTextNative {...props} />;
}
function useSheenColors(color?: string, shineColor?: string) {
const { colors } = useKinetikTheme();
return { base: color ?? colors.textMuted, shine: shineColor ?? colors.highlight };
}
function ShineTextNative({
text,
fontSize = 36,
fontWeight = '800',
fontFamily = SYSTEM_FAMILY,
color,
shineColor,
duration = 1800,
pause = 1400,
active = true,
style,
}: ShineTextProps) {
const reduced = useReduceMotion();
const { base, shine } = useSheenColors(color, shineColor);
const font = useMemo(() => {
try {
return matchFont({ fontFamily, fontSize, fontWeight: fontWeight as never });
} catch {
// No such family on this device (or no Skia font manager).
return null;
}
}, [fontFamily, fontSize, fontWeight]);
// Without a matching system font (or a Skia runtime), fall back to plain text.
const measured = useMemo(() => {
if (!font) return null;
const metrics = font.getMetrics();
const ascent = Math.abs(metrics.ascent);
return {
width: Math.ceil(font.measureText(text).width) + 2,
ascent,
height: Math.ceil(ascent + Math.abs(metrics.descent) + 2),
};
}, [font, text]);
const width = measured?.width ?? 0;
const height = measured?.height ?? 0;
const ascent = measured?.ascent ?? 0;
const band = Math.max(fontSize * 2, width * 0.35);
const progress = useSharedValue(0);
useEffect(() => {
if (reduced || !active) {
cancelAnimation(progress);
progress.set(0);
return;
}
progress.set(0);
progress.set(
withRepeat(
withDelay(pause, withTiming(1, { duration, easing: Easing.inOut(Easing.cubic) })),
-1,
),
);
return () => cancelAnimation(progress);
}, [reduced, active, duration, pause, progress]);
// The sheen starts left of the text and leaves past its right edge.
const start = useDerivedValue(() => vec(-band + progress.get() * (width + band), 0));
const end = useDerivedValue(() => vec(progress.get() * (width + band), height * 0.4));
if (!font || !measured) {
return (
<View accessible accessibilityRole="text" accessibilityLabel={text} style={style}>
<Text numberOfLines={1} style={{ fontSize, fontWeight, fontFamily, color: base }}>
{text}
</Text>
</View>
);
}
return (
<View
accessible
accessibilityRole="text"
accessibilityLabel={text}
style={[{ width, height }, style]}
>
<View
pointerEvents="none"
style={StyleSheet.absoluteFill}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Canvas style={StyleSheet.absoluteFill}>
<SkiaText x={0} y={ascent + 1} text={text} font={font} color={base}>
{!reduced && (
<LinearGradient
start={start}
end={end}
colors={[base, base, shine, base, base]}
positions={[0, 0.35, 0.5, 0.65, 1]}
/>
)}
</SkiaText>
</Canvas>
</View>
</View>
);
}
function ShineTextWeb({
text,
fontSize = 36,
fontWeight = '800',
fontFamily,
color,
shineColor,
duration = 1800,
pause = 1400,
active = true,
style,
}: ShineTextProps) {
const reduced = useReduceMotion();
const { base, shine } = useSheenColors(color, shineColor);
const cycle = duration + pause;
const sweepEnd = Math.round((pause / cycle) * 100);
const animate = active && !reduced;
// react-native-web passes these CSS properties through to the DOM.
const css = {
backgroundImage: `linear-gradient(110deg, ${base} 0%, ${base} 40%, ${shine} 50%, ${base} 60%, ${base} 100%)`,
backgroundSize: '250% 100%',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
...(animate
? {
animationKeyframes: [
{
'0%': { backgroundPosition: '100% 0' },
[`${sweepEnd}%`]: { backgroundPosition: '100% 0' },
'100%': { backgroundPosition: '0% 0' },
},
],
animationDuration: `${cycle}ms`,
animationIterationCount: 'infinite',
animationTimingFunction: 'ease-in-out',
}
: { backgroundPosition: '100% 0' }),
} as unknown as TextStyle;
return (
<View accessible accessibilityRole="text" accessibilityLabel={text} style={style}>
<Text
numberOfLines={1}
importantForAccessibility="no"
style={[{ fontSize, fontWeight, fontFamily, color: base }, css]}
>
{text}
</Text>
</View>
);
}Installation
npx kinetik-ui add shine-textpnpm dlx kinetik-ui add shine-textbunx kinetik-ui add shine-textyarn dlx kinetik-ui add shine-textThe 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/shine-text.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/shine-text.tsx
Usage
import { ShineText } from '@/components/kinetik/shine-text';
<ShineText text="Unlock Pro" fontSize={40} />;The sheen is a real gradient masked by the glyphs: drawn with Skia on iOS and Android and with CSS background-clip: text on the web.
When to use
- One premium or celebratory phrase: an upgrade, a reward, a limited offer.
- Short, single-line labels and headlines.
When not to use
- Body text or several lines. ShineText draws a single line.
- More than one shining element on screen; the effect only works when it is rare.
Notes
fontFamilydefaults to the system sans-serif; pass a family that is installed on the device (or loaded in the app) for custom type.- Between sweeps (
pause) nothing animates. Passactive={false}while off-screen. - If no matching font is available the component falls back to plain text.
Props
ShineTextProps
| Prop | Type | Default | Description |
|---|---|---|---|
textrequired | string | — | Single line of text. |
fontSize | number | 36 | Font size in pt. Defaults to 36. |
fontWeight | TextStyle['fontWeight'] | '800' | Defaults to '800'. |
fontFamily | string | — | Font family. Defaults to the system sans-serif. |
color | string | — | Base text colour. Defaults to the theme textMuted. |
shineColor | string | — | Colour of the sheen. Defaults to the theme highlight. |
duration | number | 1800 | Time for one sweep, in ms. Defaults to 1800. |
pause | number | 1400 | Rest between sweeps, in ms. Defaults to 1400. |
active | boolean | true | Stop sweeping, e.g. while off-screen. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Native: one text draw with a moving gradient per frame while sweeping; idle between sweeps. Web: a CSS animation.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.37× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 152 MB app memory. Sweeps by design while active; pass active={false} to stop.
Real-device measurement pending.
Accessibility
- Reduced motion
- Plain text in the base colour, no sheen.
- Screen readers
- Exposed as a single text element with the full string.
- Touch target
- Not interactive.
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/shine-text.tsx
import {
Canvas,
LinearGradient,
Text as SkiaText,
matchFont,
vec,
} from '@shopify/react-native-skia';
import { useEffect, useMemo } from 'react';
import {
Platform,
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import {
Easing,
cancelAnimation,
useDerivedValue,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ShineTextProps = {
/** Single line of text. */
text: string;
/** Font size in pt. Defaults to 36. */
fontSize?: number;
/** Defaults to '800'. */
fontWeight?: TextStyle['fontWeight'];
/** Font family. Defaults to the system sans-serif. */
fontFamily?: string;
/** Base text colour. Defaults to the theme textMuted. */
color?: string;
/** Colour of the sheen. Defaults to the theme highlight. */
shineColor?: string;
/** Time for one sweep, in ms. Defaults to 1800. */
duration?: number;
/** Rest between sweeps, in ms. Defaults to 1400. */
pause?: number;
/** Stop sweeping, e.g. while off-screen. Defaults to true. */
active?: boolean;
style?: StyleProp<ViewStyle>;
};
const SYSTEM_FAMILY = Platform.select({ ios: 'Helvetica Neue', default: 'sans-serif' });
/**
* A single line of text with a band of light sweeping across it. The sheen
* is a real gradient masked by the glyphs: Skia on iOS and Android, CSS
* background-clip on the web.
*/
export function ShineText(props: ShineTextProps) {
return Platform.OS === 'web' ? <ShineTextWeb {...props} /> : <ShineTextNative {...props} />;
}
function useSheenColors(color?: string, shineColor?: string) {
const { colors } = useKinetikTheme();
return { base: color ?? colors.textMuted, shine: shineColor ?? colors.highlight };
}
function ShineTextNative({
text,
fontSize = 36,
fontWeight = '800',
fontFamily = SYSTEM_FAMILY,
color,
shineColor,
duration = 1800,
pause = 1400,
active = true,
style,
}: ShineTextProps) {
const reduced = useReduceMotion();
const { base, shine } = useSheenColors(color, shineColor);
const font = useMemo(() => {
try {
return matchFont({ fontFamily, fontSize, fontWeight: fontWeight as never });
} catch {
// No such family on this device (or no Skia font manager).
return null;
}
}, [fontFamily, fontSize, fontWeight]);
// Without a matching system font (or a Skia runtime), fall back to plain text.
const measured = useMemo(() => {
if (!font) return null;
const metrics = font.getMetrics();
const ascent = Math.abs(metrics.ascent);
return {
width: Math.ceil(font.measureText(text).width) + 2,
ascent,
height: Math.ceil(ascent + Math.abs(metrics.descent) + 2),
};
}, [font, text]);
const width = measured?.width ?? 0;
const height = measured?.height ?? 0;
const ascent = measured?.ascent ?? 0;
const band = Math.max(fontSize * 2, width * 0.35);
const progress = useSharedValue(0);
useEffect(() => {
if (reduced || !active) {
cancelAnimation(progress);
progress.set(0);
return;
}
progress.set(0);
progress.set(
withRepeat(
withDelay(pause, withTiming(1, { duration, easing: Easing.inOut(Easing.cubic) })),
-1,
),
);
return () => cancelAnimation(progress);
}, [reduced, active, duration, pause, progress]);
// The sheen starts left of the text and leaves past its right edge.
const start = useDerivedValue(() => vec(-band + progress.get() * (width + band), 0));
const end = useDerivedValue(() => vec(progress.get() * (width + band), height * 0.4));
if (!font || !measured) {
return (
<View accessible accessibilityRole="text" accessibilityLabel={text} style={style}>
<Text numberOfLines={1} style={{ fontSize, fontWeight, fontFamily, color: base }}>
{text}
</Text>
</View>
);
}
return (
<View
accessible
accessibilityRole="text"
accessibilityLabel={text}
style={[{ width, height }, style]}
>
<View
pointerEvents="none"
style={StyleSheet.absoluteFill}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Canvas style={StyleSheet.absoluteFill}>
<SkiaText x={0} y={ascent + 1} text={text} font={font} color={base}>
{!reduced && (
<LinearGradient
start={start}
end={end}
colors={[base, base, shine, base, base]}
positions={[0, 0.35, 0.5, 0.65, 1]}
/>
)}
</SkiaText>
</Canvas>
</View>
</View>
);
}
function ShineTextWeb({
text,
fontSize = 36,
fontWeight = '800',
fontFamily,
color,
shineColor,
duration = 1800,
pause = 1400,
active = true,
style,
}: ShineTextProps) {
const reduced = useReduceMotion();
const { base, shine } = useSheenColors(color, shineColor);
const cycle = duration + pause;
const sweepEnd = Math.round((pause / cycle) * 100);
const animate = active && !reduced;
// react-native-web passes these CSS properties through to the DOM.
const css = {
backgroundImage: `linear-gradient(110deg, ${base} 0%, ${base} 40%, ${shine} 50%, ${base} 60%, ${base} 100%)`,
backgroundSize: '250% 100%',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
...(animate
? {
animationKeyframes: [
{
'0%': { backgroundPosition: '100% 0' },
[`${sweepEnd}%`]: { backgroundPosition: '100% 0' },
'100%': { backgroundPosition: '0% 0' },
},
],
animationDuration: `${cycle}ms`,
animationIterationCount: 'infinite',
animationTimingFunction: 'ease-in-out',
}
: { backgroundPosition: '100% 0' }),
} as unknown as TextStyle;
return (
<View accessible accessibilityRole="text" accessibilityLabel={text} style={style}>
<Text
numberOfLines={1}
importantForAccessibility="no"
style={[{ fontSize, fontWeight, fontFamily, color: base }, css]}
>
{text}
</Text>
</View>
);
}