Radiant Button

A Skia-powered animated button with a moving shimmer

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @shopify/react-native-skia

Copy and paste the following code into your project. component/base/radiant-button

import React, { memo, useEffect, useMemo, useState } from "react";import {  Pressable,  StyleSheet,  View,  Text,  type LayoutChangeEvent,} from "react-native";import {  Canvas,  RoundedRect,  vec,  Group,  LinearGradient,  Rect,  Fill,  Shader,  Skia,  BlurMask,  Mask,} from "@shopify/react-native-skia";import Animated, {  useSharedValue,  useDerivedValue,  withRepeat,  withTiming,  Easing,  useAnimatedStyle,  interpolate,} from "react-native-reanimated";import { BORDER_GLOW_SHADER } from "./conf";import { createDotShaderSource, hexToRgb } from "./helpers";import type { IRadiantButton } from "./types";export const RadiantButton: React.FC<IRadiantButton> &  React.FunctionComponent<IRadiantButton> = memo<  IRadiantButton | React.ComponentProps<typeof RadiantButton>>(  ({    children,    onPress,    style,    textStyle,    borderRadius = 12,    borderWidth = 2,    duration = 3000,    theme: themeProp,    paddingHorizontal = 24,    paddingVertical = 14,    disabled = false,    showDots = true,    showShimmer = true,    showGlow = true,    dotSpacing = 5,    dotRadius = 0.65,    dotOpacity = 0.35,    shimmerOpacity = 0.35,    glowBlur = 18,    glowWidth = 0.7,    breathingEnabled = true,    glowBandWidth = 0.15,  }: IRadiantButton | React.ComponentProps<typeof RadiantButton>):    | (React.JSX.Element & React.ReactNode & React.ReactElement)    | null => {    const progress = useSharedValue<number>(0);    const shimmerAngle = useSharedValue<number>(0);    const breathe = useSharedValue<number>(0);    const pressed = useSharedValue<number>(0);    const defaultTheme = {      background: "#000000",      backgroundSubtle: "#1a1a1a",      foreground: "#ffffff",      highlight: "#c084fc",      highlightSubtle: "#a855f7",    };    const theme = useMemo(      () => ({ ...defaultTheme, ...themeProp }),      [themeProp, defaultTheme],    );    const highlightRgb = useMemo<number[]>(      () => hexToRgb(theme.highlight || "#67e8f9"),      [theme.highlight],    );    const [layout, setLayout] = useState<{ width: number; height: number }>({      width: 0,      height: 0,    });    const borderGlowShader = useMemo(() => {      try {        return Skia.RuntimeEffect.Make(BORDER_GLOW_SHADER);      } catch {        return null;      }    }, []);    const dotShader = useMemo(() => {      if (!showDots) return null;      try {        return Skia.RuntimeEffect.Make(          createDotShaderSource<number, number>(            dotSpacing,            dotRadius,            dotOpacity,            true,          ),        );      } catch {        return null;      }    }, [showDots, dotSpacing, dotRadius, dotOpacity]);    useEffect(() => {      if (disabled) return;      progress.value = withRepeat<number>(        withTiming(1, { duration, easing: Easing.linear }),        -1,        false,      );      if (showShimmer) {        shimmerAngle.value = withRepeat<number>(          withTiming(360, {            duration: duration / 0.4,            easing: Easing.linear,          }),          -1,          false,        );      }      if (breathingEnabled && showGlow) {        breathe.value = withRepeat<number>(          withTiming(1, {            duration: duration * 1.5,            easing: Easing.inOut(Easing.sin),          }),          -1,          true,        );      }    }, [disabled, duration, showShimmer, showGlow, breathingEnabled]);    const handleLayout = <T extends LayoutChangeEvent>(e: T) => {      const { width: w, height: h } = e.nativeEvent.layout;      if (w !== layout.width || h !== layout.height) {        setLayout({ width: w, height: h });      }    };    const { width, height } = layout;    const cx = width / 2;    const cy = height / 2;    const innerClip = useMemo(() => {      if (!width || !height) return undefined;      const bw = borderWidth;      const p = Skia.Path.Make();      p.addRRect(        Skia.RRectXY(          Skia.XYWHRect(bw, bw, width - bw * 2, height - bw * 2),          Math.max(borderRadius - bw, 0),          Math.max(borderRadius - bw, 0),        ),      );      return p;    }, [width, height, borderRadius, borderWidth]);    const borderGlowUniforms = useDerivedValue(() => {      const bandWidthAdjusted = interpolate(        pressed.value,        [0, 1],        [glowBandWidth, glowBandWidth * 2],      );      return {        iResolution: [width, height] as [number, number],        progress: progress.value,        borderRadius: borderRadius,        borderWidth: borderWidth,        bandWidth: bandWidthAdjusted,        highlightColor: highlightRgb,      };    });    const dotUniforms = useDerivedValue(() => ({      iResolution: [width, height] as [number, number],      angle: progress.value * Math.PI * 2,    }));    const shimmerTransform = useDerivedValue(() => {      const rad = (shimmerAngle.value * Math.PI) / 180;      return [{ rotate: rad }];    });    const glowOpacity = useDerivedValue<number>(() => {      const base = 0.25;      const breatheAdd = breathingEnabled        ? interpolate(breathe.value, [0, 1], [0, 0.2])        : 0;      const pressAdd = interpolate(pressed.value, [0, 1], [0, 0.5]);      return base + breatheAdd + pressAdd;    });    const glowScale = useDerivedValue<number>(() =>      breathingEnabled ? interpolate(breathe.value, [0, 1], [1, 1.15]) : 1,    );    const glowTransform = useDerivedValue(() => [{ scale: glowScale.value }]);    const handlePressIn = () => {      pressed.value = withTiming<number>(1, { duration: 300 });    };    const handlePressOut = () => {      pressed.value = withTiming<number>(0, { duration: 600 });    };    const animatedPressStyle = useAnimatedStyle(() => ({      transform: [{ translateY: interpolate(pressed.value, [0, 1], [0, 1]) }],    }));    const shimmerSize = Math.max(width, height) * 1.5;    const hasLayout = width > 0 && height > 0;    const renderChildren = () => {      if (typeof children === "string") {        return (          <Text style={[styles.text, { color: theme.foreground }, textStyle]}>            {children}          </Text>        );      }      return children;    };    return (      <Animated.View style={animatedPressStyle}>        <Pressable          onPress={onPress}          onPressIn={handlePressIn}          onPressOut={handlePressOut}          onLayout={handleLayout}          disabled={disabled}          style={[            styles.button,            {              borderRadius,              paddingHorizontal,              paddingVertical,              opacity: disabled ? 0.5 : 1,            },            style,          ]}        >          {hasLayout && innerClip && (            <Canvas              style={{                ...StyleSheet.absoluteFillObject,              }}            >              {borderGlowShader && (                <Group>                  <Rect x={0} y={0} width={width} height={height}>                    <Shader                      source={borderGlowShader}                      uniforms={borderGlowUniforms}                    />                    <BlurMask blur={6} style="normal" />                  </Rect>                  <Rect x={0} y={0} width={width} height={height}>                    <Shader                      source={borderGlowShader}                      uniforms={borderGlowUniforms}                    />                  </Rect>                </Group>              )}              <Group clip={innerClip}>                <Rect                  x={borderWidth}                  y={borderWidth}                  width={width - borderWidth * 2}                  height={height - borderWidth * 2}                  color={theme.background}                />                <RoundedRect                  x={borderWidth}                  y={borderWidth}                  width={width - borderWidth * 2}                  height={height - borderWidth * 2}                  r={Math.max(borderRadius - borderWidth, 0)}                  color={theme.backgroundSubtle}                  style="stroke"                  strokeWidth={1}                />                {showDots && dotShader && (                  <Fill>                    <Shader source={dotShader} uniforms={dotUniforms} />                  </Fill>                )}                {showShimmer && (                  <Mask                    mask={                      <Group>                        <Rect                          x={borderWidth}                          y={borderWidth}                          width={width - borderWidth * 2}                          height={height - borderWidth * 2}                        >                          <LinearGradient                            start={vec(cx, borderWidth)}                            end={vec(cx, height - borderWidth)}                            colors={["transparent", "transparent", "white"]}                            positions={[0, 0.4, 1]}                          />                        </Rect>                      </Group>                    }                  >                    <Group                      transform={shimmerTransform}                      origin={vec(cx, cy)}                      opacity={shimmerOpacity}                    >                      <Rect                        x={cx - shimmerSize / 2}                        y={cy - shimmerSize / 2}                        width={shimmerSize}                        height={shimmerSize}                      >                        <LinearGradient                          start={vec(0, 0)}                          end={vec(shimmerSize, shimmerSize * 0.7)}                          colors={[                            "transparent",                            "transparent",                            theme.highlight || "#67e8f9",                            "transparent",                            "transparent",                          ]}                          positions={[0, 0.35, 0.5, 0.65, 1]}                        />                      </Rect>                    </Group>                  </Mask>                )}                {showGlow && (                  <Group                    transform={glowTransform}                    origin={vec(cx, height)}                    opacity={glowOpacity}                  >                    <RoundedRect                      x={cx - (width * glowWidth) / 2}                      y={height - 22}                      width={width * glowWidth}                      height={26}                      r={13}                      color={theme.highlight || "#67e8f9"}                    >                      <BlurMask blur={glowBlur} style="normal" />                    </RoundedRect>                  </Group>                )}                {showGlow && (                  <Group opacity={20}>                    <RoundedRect                      x={cx - width * 0.3}                      y={height - borderWidth - 300}                      width={width * 0.5}                      height={3}                      r={1.5}                      color={theme.highlight || "#67e8f9"}                    >                      <BlurMask blur={3} style="normal" />                    </RoundedRect>                  </Group>                )}              </Group>            </Canvas>          )}          <View style={styles.content}>{renderChildren()}</View>        </Pressable>      </Animated.View>    );  },);const styles = StyleSheet.create({  button: {    position: "relative",    overflow: "hidden",    minHeight: 48,    justifyContent: "center",    alignItems: "center",  },  content: {    flexDirection: "row",    alignItems: "center",    justifyContent: "center",    zIndex: 1,  },  text: {    fontSize: 16,    fontWeight: "500",  },});export default memo<  React.FC<IRadiantButton> & React.FunctionComponent<IRadiantButton>>(RadiantButton);

Usage

import { Text, StyleSheet, View } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useState } from "react";import { useFonts } from "expo-font";import RadiantButton from "@/components/base/radiant-button";import { Entypo } from "@expo/vector-icons";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    StretchPro: require("@/assets/fonts/StretchPro.otf"),  });  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <RadiantButton style={styles.button}>        <View style={styles.row}>          <Entypo name="heart" size={17} color="#fff" />          <Text            style={[              styles.text,              {                fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined,              },            ]}          >            Get Started          </Text>        </View>      </RadiantButton>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",    justifyContent: "center",    alignItems: "center",    paddingHorizontal: 24,  },  button: {    width: 250,  },  row: {    flexDirection: "row",    alignItems: "center",    justifyContent: "center",    paddingHorizontal: 24,    gap: 14,  },  iconWrapper: {    width: 34,    height: 34,    borderRadius: 12,    backgroundColor: "rgba(255,255,255,0.15)",    justifyContent: "center",    alignItems: "center",  },  text: {    fontSize: 17,    color: "#fff",    letterSpacing: 0.5,  },});

Props

React Native Reanimated
React Native Skia

On this page