Gooey Text
Morphing text that smoothly blends between words using a gooey blur
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @shopify/react-native-skiaCopy and paste the following code into your project.
component/molecules/gooey-text
import React, { memo, useEffect, useMemo } from "react";import { View, StyleSheet, Platform, type TextStyle } from "react-native";import { Canvas, Text, useFont, Group, Paint, Blur, ColorMatrix, matchFont, Skia,} from "@shopify/react-native-skia";import { useSharedValue, useDerivedValue, withRepeat, withTiming, Easing, cancelAnimation,} from "react-native-reanimated";import type { IGooeyText, IGooeyTextItem } from "./types";import { THRESHOLD_MATRIX } from "./conf";import { calculateBlur, calculateOpacity } from "./helpers";const GooeyTextItem: React.FC<IGooeyTextItem> & React.FunctionComponent<IGooeyTextItem> = ({ text, index, totalTexts, masterClock, cooldownFraction, font, color, x, y,}: IGooeyTextItem): React.ReactElement & React.ReactNode & React.JSX.Element => { const visibility = useDerivedValue(() => { const cycleIndex = Math.floor(masterClock.value) % totalTexts; const nextIndex = (cycleIndex + 1) % totalTexts; const progressInCycle = masterClock.value % 1; let morphProgress = 0; if (progressInCycle >= cooldownFraction) { morphProgress = (progressInCycle - cooldownFraction) / (1 - cooldownFraction); } if (index === cycleIndex) { return 1 - morphProgress; } else if (index === nextIndex) { return morphProgress; } else { return 0; } }, [index, totalTexts, cooldownFraction]); const blur = useDerivedValue(() => { return calculateBlur<number>(visibility.value); }, []); const opacity = useDerivedValue(() => { return calculateOpacity<number>(visibility.value); }, []); return ( <Group layer={ <Paint> <Blur blur={blur} /> </Paint> } opacity={opacity} > <Text x={x} y={y} text={text} font={font} color={color} /> </Group> );};function useSystemFont< T extends string, S extends number, W extends TextStyle["fontWeight"], P extends boolean,>(fontFamily: T | undefined, fontSize: S, fontWeight: W, skip: P) { return useMemo(() => { if (skip) return null; const family = fontFamily ?? Platform.select({ ios: "Helvetica", android: "sans-serif", default: "serif", }); try { return matchFont({ fontFamily: family, fontSize, fontWeight: fontWeight as any, }); } catch { const fontMgr = Skia.FontMgr.System(); const numericWeight = fontWeight === "bold" ? 700 : fontWeight === "normal" ? 400 : parseInt(fontWeight as string, 10) || 400; const typeface = fontMgr.matchFamilyStyle(family, { weight: numericWeight, }); return typeface ? Skia.Font(typeface, fontSize) : null; } }, [skip, fontFamily, fontSize, fontWeight]);}export const GooeyText: React.FC<IGooeyText> & React.FunctionComponent<IGooeyText> = memo<IGooeyText>( ({ texts, morphTime = 1, cooldownTime = 0.25, style, fontSize = 48, color = "black", fontSource, fontFamily, fontWeight = "bold", width = 300, height = 100, }: IGooeyText): React.ReactNode & React.JSX.Element & React.ReactElement => { const customFont = useFont(fontSource ?? null, fontSize); const systemFont = useSystemFont( fontFamily, fontSize, fontWeight as TextStyle["fontWeight"], !!fontSource, ); const font = fontSource ? customFont : systemFont; const cycleTime = cooldownTime + morphTime; const cooldownFraction = cooldownTime / cycleTime; const totalDuration = cycleTime * texts.length; const masterClock = useSharedValue<number>(0); useEffect(() => { if (texts.length < 2 || !font) return; masterClock.value = 0; masterClock.value = withRepeat( withTiming(texts.length, { duration: totalDuration * 1000, easing: Easing.linear, }), -1, false, ); return () => { cancelAnimation<number>(masterClock); }; }, [texts.length, totalDuration, font, masterClock]); const textPositions = useMemo(() => { if (!font) return texts.map<number>((_) => width / 2); return texts.map((text) => { try { const measured = font.measureText(text); return (width - measured.width) / 2; } catch { return width / 2; } }); }, [font, width, texts]); const textY = height / 2 + fontSize / 3; if (!font) { return <View style={[styles.container, { width, height }, style]} />; } if (texts.length < 2) { return ( <View style={[styles.container, { width, height }, style]}> <Canvas style={styles.canvas}> <Text x={textPositions[0] ?? width / 2} y={textY} text={texts[0] ?? ""} font={font} color={color} /> </Canvas> </View> ); } return ( <View style={[styles.container, { width, height }, style]}> <Canvas style={styles.canvas}> <Group layer={ <Paint> <ColorMatrix matrix={THRESHOLD_MATRIX} /> </Paint> } > {texts.map<React.ReactElement<IGooeyTextItem>>( (text: string, index: number) => ( <GooeyTextItem key={`${text}-${index}`} text={text} index={index} totalTexts={texts.length} masterClock={masterClock} cooldownFraction={cooldownFraction} font={font} color={color} x={textPositions[index] ?? width / 2} y={textY} /> ), )} </Group> </Canvas> </View> ); },);const styles = StyleSheet.create({ container: { overflow: "hidden", }, canvas: { flex: 1, },});export default memo(GooeyText);Usage
import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { DisclosureGroup } from "@/components/molecules/disclosure-group";import DynamicText from "@/components/molecules/dynamic-text";import { DynamicTextItem } from "@/components/molecules/dynamic-text/types";import GooeyText from "@/components/molecules/gooey-text";export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const OPTIONS = [ { label: "Edit", icon: "pencil" }, { label: "Duplicate", icon: "doc.on.doc" }, { label: "Share", icon: "square.and.arrow.up" }, { label: "Delete", icon: "trash", destructive: true }, ]; const GOOEY_TEXTS: string[] = ["REACTICX", "IS", "AWESOME!"]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <GooeyText texts={GOOEY_TEXTS} color="white" fontSize={50} style={{ marginLeft: 50, marginTop: 50, }} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, content: { paddingHorizontal: 20, paddingTop: 50, gap: 0, }, title: { fontSize: 28, fontWeight: "700", color: "#fff", }, subtitle: { fontSize: 15, color: "#555", }, card: { backgroundColor: "#141414", borderRadius: 16, overflow: "hidden", marginTop: 20, }, triggerContent: { padding: 16, }, triggerLeft: { flexDirection: "row", alignItems: "center", gap: 12, }, triggerText: { fontSize: 16, fontWeight: "500", color: "#fff", }, item: { flexDirection: "row", alignItems: "center", gap: 12, padding: 14, backgroundColor: "#1a1a1a", borderRadius: 12, marginBottom: 6, }, itemText: { fontSize: 15, color: "#fff", }, destructiveText: { color: "#ff453a", },});Props
IGooeyTextItem
React Native Reanimated
React Native Skia
