Title
A flexible title text component with multiple heading levels
Last updated on
Manual
Copy and paste the following code into your project.
component/base/title.tsx
import React, { memo, useCallback, useEffect, useRef, useState, useMemo,} from "react";import { View, Text, StyleSheet, Animated, Pressable, type TextStyle, type ViewStyle, type TextLayoutEvent,} from "react-native";import type { ITitle } from "./types";import { resolveColor, resolveSize } from "./helpers";import { DEFAULT_THEME } from "./const";const TextSkeleton: React.FC<{ width: number | string; height: number }> = ({ width, height,}) => { const opacity = useRef(new Animated.Value(0.3)).current; useEffect(() => { const animation = Animated.loop( Animated.sequence([ Animated.timing(opacity, { toValue: 1, duration: 800, useNativeDriver: true, }), Animated.timing(opacity, { toValue: 0.3, duration: 800, useNativeDriver: true, }), ]), ); animation.start(); return () => animation.stop(); }, [opacity]); return ( <Animated.View style={[ styles.textSkeleton, { width, height, opacity, } as any, ]} /> );};const TitleComponent: React.FC<ITitle> & React.FunctionComponent<ITitle> = ({ children, size, level, lineHeight, letterSpacing, style, containerStyle, weight = DEFAULT_THEME.defaultWeight, align = "left", transform = "none", decoration = "none", decorationColor, color, opacity = 1, shadow = false, shadowConfig, italic = false, numberOfLines, ellipsizeMode = "tail", maxWidth, minWidth, onPress, onLongPress, onLayout, onTextTruncated, selectable = false, suppressHighlighting = false, activeOpacity = 0.7, accessibilityLabel, accessibilityHint, accessibilityRole = "text", accessible = true, testID, loading = false, skeletonWidth = 120, animated = false, animationDuration = 300, prefix, suffix, gap = 8, allowFontScaling = true, maxFontSizeMultiplier, minimumFontScale, adjustsFontSizeToFit = false, className,}: ITitle): React.ReactNode & React.JSX.Element & React.ReactElement => { const theme = DEFAULT_THEME; const fadeAnim = useRef<Animated.Value>( new Animated.Value(animated ? 0 : 1), ).current; const [isTruncated, setIsTruncated] = useState<boolean>(false); const resolvedSize = useMemo<number>( () => resolveSize(size, level, theme), [size, level, theme], ); const resolvedColor = useMemo<string>( () => resolveColor(color, theme), [color, theme], ); const resolvedWeight = theme.fontWeights[weight]; useEffect(() => { if (animated) { Animated.timing(fadeAnim, { toValue: 1, duration: animationDuration, useNativeDriver: true, }).start(); } }, [animated, animationDuration, fadeAnim]); const handleTextLayout = useCallback( (event: TextLayoutEvent) => { if (numberOfLines && onTextTruncated) { const { lines } = event.nativeEvent; const truncated = lines.length >= numberOfLines; setIsTruncated(truncated); onTextTruncated(truncated); } }, [numberOfLines, onTextTruncated], ); const textStyles = useMemo((): TextStyle[] => { const baseStyles: TextStyle[] = [ styles.text, { fontSize: resolvedSize, fontWeight: resolvedWeight, color: resolvedColor, textAlign: align, textTransform: transform, textDecorationLine: decoration, opacity, }, ]; if (lineHeight) { baseStyles.push({ lineHeight: resolvedSize * lineHeight }); } if (letterSpacing) { baseStyles.push({ letterSpacing }); } if (italic) { baseStyles.push({ fontStyle: "italic" }); } if (decorationColor) { baseStyles.push({ textDecorationColor: decorationColor }); } if (shadow || shadowConfig) { baseStyles.push({ textShadowColor: shadowConfig?.color ?? "rgba(0, 0, 0, 0.3)", textShadowOffset: shadowConfig?.offset ?? { width: 1, height: 1 }, textShadowRadius: shadowConfig?.radius ?? 2, }); } if (maxWidth) { baseStyles.push({ maxWidth }); } if (minWidth) { baseStyles.push({ minWidth }); } if (style) { baseStyles.push(style as TextStyle); } return baseStyles; }, [ resolvedSize, resolvedWeight, resolvedColor, align, transform, decoration, opacity, lineHeight, letterSpacing, italic, decorationColor, shadow, shadowConfig, maxWidth, minWidth, style, ]); const containerStyles = useMemo((): ViewStyle[] => { const baseContainerStyles: ViewStyle[] = [styles.container]; if (prefix || suffix) { baseContainerStyles.push(styles.row, { gap }); } if (containerStyle) { baseContainerStyles.push(containerStyle as ViewStyle); } return baseContainerStyles; }, [prefix, suffix, gap, containerStyle]); if (loading) { return ( <View style={containerStyles} testID={testID}> <TextSkeleton width={skeletonWidth} height={resolvedSize} /> </View> ); } const textElement = ( <Text style={textStyles} numberOfLines={numberOfLines} ellipsizeMode={ellipsizeMode} selectable={selectable} suppressHighlighting={suppressHighlighting} allowFontScaling={allowFontScaling} maxFontSizeMultiplier={maxFontSizeMultiplier} minimumFontScale={minimumFontScale} adjustsFontSizeToFit={adjustsFontSizeToFit} onTextLayout={numberOfLines ? handleTextLayout : undefined} accessible={!onPress && accessible} accessibilityLabel={!onPress ? accessibilityLabel : undefined} accessibilityHint={!onPress ? accessibilityHint : undefined} accessibilityRole={!onPress ? accessibilityRole : undefined} testID={!onPress ? testID : undefined} > {children} </Text> ); const content: React.ReactNode & React.JSX.Element = ( <> {prefix && <View style={styles.addon}>{prefix}</View>} {textElement} {suffix && <View style={styles.addon}>{suffix}</View>} </> ); const wrappedContent = onPress ? ( <Pressable onPress={onPress} onLongPress={onLongPress} onLayout={onLayout} accessible={accessible} accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint} accessibilityRole="button" testID={testID} style={({ pressed }) => [ containerStyles, pressed && { opacity: activeOpacity }, ]} > {content} </Pressable> ) : ( <View style={containerStyles} onLayout={onLayout}> {content} </View> ); if (animated) { return ( <Animated.View style={{ opacity: fadeAnim }}> {wrappedContent} </Animated.View> ); } return wrappedContent;};const H1: React.FC<Omit<ITitle, "level">> & React.FunctionComponent<Omit<ITitle, "level">> = ( props: Omit<ITitle, "level">,): (React.ReactNode & React.JSX.Element & React.ReactElement) | null => ( <TitleComponent {...props} level="h1" />);const H2: React.FC<Omit<ITitle, "level">> & React.FunctionComponent<Omit<ITitle, "level">> = ( props: Omit<ITitle, "level">,): (React.ReactNode & React.JSX.Element & React.ReactElement) | null => ( <TitleComponent {...props} level="h2" />);const H3: React.FC<Omit<ITitle, "level">> & React.FunctionComponent<Omit<ITitle, "level">> = ( props: Omit<ITitle, "level">,): (React.ReactNode & React.JSX.Element & React.ReactElement) | null => ( <TitleComponent {...props} level="h3" />);const H4: React.FC<Omit<ITitle, "level">> & React.FunctionComponent<Omit<ITitle, "level">> = ( props: Omit<ITitle, "level">,): (React.ReactNode & React.JSX.Element & React.ReactElement) | null => ( <TitleComponent {...props} level="h4" />);const H5: React.FC<Omit<ITitle, "level">> & React.FunctionComponent<Omit<ITitle, "level">> = ( props: Omit<ITitle, "level">,): (React.ReactNode & React.JSX.Element & React.ReactElement) | null => ( <TitleComponent {...props} level="h5" />);const H6: React.FC<Omit<ITitle, "level">> & React.FunctionComponent<Omit<ITitle, "level">> = ( props: Omit<ITitle, "level">,): (React.ReactNode & React.JSX.Element & React.ReactElement) | null => ( <TitleComponent {...props} level="h6" />);export const Title = Object.assign( memo<React.FC<ITitle> & React.FunctionComponent<ITitle>>(TitleComponent), { H1: memo<Omit<ITitle, "level">>(H1), H2: memo<Omit<ITitle, "level">>(H2), H3: memo<Omit<ITitle, "level">>(H3), H4: memo<Omit<ITitle, "level">>(H4), H5: memo<Omit<ITitle, "level">>(H5), H6: memo<Omit<ITitle, "level">>(H6), },);export type { ITitle };const styles = StyleSheet.create({ container: { flexShrink: 1, }, row: { flexDirection: "row", alignItems: "center", }, text: { flexShrink: 1, }, addon: { justifyContent: "center", alignItems: "center", }, textSkeleton: { backgroundColor: "#E1E1E1", borderRadius: 4, },});Usage
import { View, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { Title } from "@/components/base/title";import { SymbolView } from "expo-symbols";import { useFonts } from "expo-font";import { useEffect, useState } from "react";export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const [loading, setLoading] = useState<boolean>(true); useEffect(() => { const timer = setTimeout(() => { setLoading(false); }, 3000); return () => clearTimeout(timer); }, []); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <Title.H1 color="#fff" weight="bold" animated loading={loading}> Reactix </Title.H1> <Title.H4 color="#616161" animated size={17} loading={loading} weight="normal" prefix={ <SymbolView name="hand.wave.fill" size={18} tintColor="#616161" /> } style={fontLoaded ? { fontFamily: "SfProRounded" } : undefined} > Welcome back and have a nice day! </Title.H4> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", paddingHorizontal: 24, }, content: { gap: 8, top: 120, },});Props
ITitleTheme
React Native
