Kinetik UI

Shine Text

.md

A line of text with a band of light sweeping across the glyphs.

v1.0.0Live on web@shopify/react-native-skiareact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add shine-text

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/shine-text.json

Install manually

  1. Install dependencies
    npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/shine-text.tsx
    View source files

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

  • fontFamily defaults 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. Pass active={false} while off-screen.
  • If no matching font is available the component falls back to plain text.

Props

ShineTextProps

PropTypeDefaultDescription
textrequiredstring—Single line of text.
fontSizenumber36Font size in pt. Defaults to 36.
fontWeightTextStyle['fontWeight']'800'Defaults to '800'.
fontFamilystring—Font family. Defaults to the system sans-serif.
colorstring—Base text colour. Defaults to the theme textMuted.
shineColorstring—Colour of the sheen. Defaults to the theme highlight.
durationnumber1800Time for one sweep, in ms. Defaults to 1800.
pausenumber1400Rest between sweeps, in ms. Defaults to 1400.
activebooleantrueStop sweeping, e.g. while off-screen. Defaults to true.
styleStyleProp<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>
  );
}