Created
July 24, 2026 23:46
-
-
Save byteab/a4c6efcebf1b73032ac88ee75092dbde to your computer and use it in GitHub Desktop.
text peel effect in react native
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import MaskedView from '@react-native-masked-view/masked-view' | |
| import { Stack } from 'expo-router' | |
| import { useMemo } from 'react' | |
| import { Platform, StyleSheet, Text, useWindowDimensions, View } from 'react-native' | |
| import { Gesture, GestureDetector } from 'react-native-gesture-handler' | |
| import Animated, { | |
| cancelAnimation, | |
| Easing, | |
| useAnimatedStyle, | |
| useDerivedValue, | |
| useSharedValue, | |
| withTiming, | |
| } from 'react-native-reanimated' | |
| import Svg, { Defs, FeGaussianBlur, Filter, Text as SvgText } from 'react-native-svg' | |
| /** | |
| * A word peeled like a sticker. The fold is a reflection about a moving crease: | |
| * the base keeps one half-plane, the flap gets the other reflected across it. | |
| */ | |
| function clamp(v: number, lo: number, hi: number) { | |
| 'worklet' | |
| return Math.min(hi, Math.max(lo, v)) | |
| } | |
| const TEXT = 'ABC' | |
| const STAGE = 440 | |
| const CENTER = STAGE / 2 | |
| const CLIP = STAGE * 3 | |
| const HALF_CLIP = CLIP / 2 | |
| const FONT = Platform.select({ ios: 'Arial Rounded MT Bold', default: undefined }) | |
| const FONT_SIZE = clamp(560 / TEXT.length, 46, 104) | |
| const LETTER_SPACING = FONT_SIZE * -0.01 | |
| const HALO_WIDTH = 8 | |
| const RIM_WIDTH = 2 | |
| const INK = '#1c1c1e' | |
| const RIM_COLOR = '#c6cad1' | |
| const SHADOW_COLOR = '#6f727c' | |
| const SHADE = '95, 101, 112' | |
| const INITIAL_PEEL = 30 | |
| const LIFT_THRESHOLD = 0.5 | |
| const GRAB_RADIUS = 40 | |
| const GRAB_SLACK = 12 | |
| const CREASE_LENGTH = STAGE * 1.6 | |
| const CREASE_THICKNESS = 5 | |
| const SHADOW_PAD = 60 | |
| // ----------------------------------------------------------------------------- | |
| // Fold geometry | |
| // ----------------------------------------------------------------------------- | |
| type Affine = [number, number, number, number, number, number] | |
| function mat4(a: number, b: number, c: number, d: number, tx: number, ty: number): number[] { | |
| 'worklet' | |
| return [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, tx, ty, 0, 1] | |
| } | |
| function stageSpan(cos: number, sin: number) { | |
| 'worklet' | |
| const x = STAGE * cos | |
| const y = STAGE * sin | |
| return { min: Math.min(0, x, x + y, y), max: Math.max(0, x, x + y, y) } | |
| } | |
| type Fold = { | |
| cos: number | |
| sin: number | |
| creaseOffset: number | |
| creaseX: number | |
| creaseY: number | |
| flap: Affine | |
| flapSpan: number | |
| } | |
| function computeFold(peel: number, peelDir: number): Fold { | |
| 'worklet' | |
| const rad = (peelDir * Math.PI) / 180 | |
| const cos = Math.cos(rad) | |
| const sin = Math.sin(rad) | |
| const { min, max } = stageSpan(cos, sin) | |
| const creaseOffset = max - (peel / 100) * (max - min) | |
| const creaseX = creaseOffset * cos | |
| const creaseY = creaseOffset * sin | |
| // p' = M p + (q - M q), which pins q and with it the whole crease line. | |
| const cos2 = Math.cos(2 * rad) | |
| const sin2 = Math.sin(2 * rad) | |
| return { | |
| cos, | |
| sin, | |
| creaseOffset, | |
| creaseX, | |
| creaseY, | |
| flap: [ | |
| -cos2, | |
| -sin2, | |
| -sin2, | |
| cos2, | |
| creaseX + cos2 * creaseX + sin2 * creaseY, | |
| creaseY + sin2 * creaseX - cos2 * creaseY, | |
| ], | |
| flapSpan: Math.max(1, creaseOffset - min), | |
| } | |
| } | |
| function clipperMatrix(fold: Fold, keepBase: boolean): number[] { | |
| 'worklet' | |
| const creaseEdge = keepBase ? CLIP : 0 | |
| return mat4( | |
| fold.cos, | |
| fold.sin, | |
| -fold.sin, | |
| fold.cos, | |
| fold.creaseX - (fold.cos * creaseEdge - fold.sin * HALF_CLIP), | |
| fold.creaseY - (fold.sin * creaseEdge + fold.cos * HALF_CLIP) | |
| ) | |
| } | |
| function clipperInnerMatrix(fold: Fold, keepBase: boolean): number[] { | |
| 'worklet' | |
| return mat4( | |
| fold.cos, | |
| -fold.sin, | |
| fold.sin, | |
| fold.cos, | |
| (keepBase ? CLIP : 0) - fold.creaseOffset, | |
| HALF_CLIP | |
| ) | |
| } | |
| function bandMatrix(fold: Fold, span: number, dir: 1 | -1): number[] { | |
| 'worklet' | |
| const k = (dir * span) / STAGE | |
| return mat4( | |
| fold.cos * k, | |
| fold.sin * k, | |
| -fold.sin, | |
| fold.cos, | |
| fold.creaseX + fold.sin * HALF_CLIP, | |
| fold.creaseY - fold.cos * HALF_CLIP | |
| ) | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // Hit testing | |
| // ----------------------------------------------------------------------------- | |
| type Grab = { | |
| anchorX: number | |
| anchorY: number | |
| pullX: number | |
| pullY: number | |
| } | |
| function findGrab( | |
| px: number, | |
| py: number, | |
| peel: number, | |
| peelDir: number, | |
| textW: number, | |
| textH: number | |
| ): Grab | null { | |
| 'worklet' | |
| if (textW <= 0) return null | |
| const fold = computeFold(peel, peelDir) | |
| const pad = HALO_WIDTH / 2 + RIM_WIDTH | |
| const left = CENTER - textW / 2 - pad | |
| const right = CENTER + textW / 2 + pad | |
| const top = CENTER - textH / 2 - pad | |
| const bottom = CENTER + textH / 2 + pad | |
| const corners: [number, number][] = [ | |
| [left, top], | |
| [right, top], | |
| [right, bottom], | |
| [left, bottom], | |
| ] | |
| const peeled = peel > LIFT_THRESHOLD | |
| const normals: [number, number][] = peeled | |
| ? [[fold.cos, fold.sin]] | |
| : [ | |
| [-1, 0], | |
| [1, 0], | |
| ] | |
| for (const [nx, ny] of normals) { | |
| let leadingEdge = -Infinity | |
| let sideMin = Infinity | |
| let sideMax = -Infinity | |
| for (const [cx, cy] of corners) { | |
| leadingEdge = Math.max(leadingEdge, cx * nx + cy * ny) | |
| const side = cy * nx - cx * ny | |
| sideMin = Math.min(sideMin, side) | |
| sideMax = Math.max(sideMax, side) | |
| } | |
| const creaseProj = fold.creaseX * nx + fold.creaseY * ny | |
| const along = px * nx + py * ny | |
| const tip = peeled ? Math.min(2 * creaseProj - leadingEdge, leadingEdge) : leadingEdge | |
| if (Math.abs(along - tip) > GRAB_RADIUS) continue | |
| const across = py * nx - px * ny | |
| if (across < sideMin - GRAB_SLACK || across > sideMax + GRAB_SLACK) continue | |
| const anchor = peeled ? Math.min(2 * creaseProj - along, leadingEdge) : leadingEdge | |
| return { | |
| anchorX: px + (anchor - along) * nx, | |
| anchorY: py + (anchor - along) * ny, | |
| pullX: nx, | |
| pullY: ny, | |
| } | |
| } | |
| return null | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // Paper | |
| // ----------------------------------------------------------------------------- | |
| type WordProps = { fill: string; stroke?: string; strokeWidth?: number; filter?: string } | |
| function Word({ fill, stroke, strokeWidth, filter }: WordProps) { | |
| return ( | |
| <SvgText | |
| x={CENTER} | |
| y={CENTER} | |
| textAnchor="middle" | |
| alignmentBaseline="central" | |
| fontFamily={FONT} | |
| fontWeight="900" | |
| fontSize={FONT_SIZE} | |
| letterSpacing={LETTER_SPACING} | |
| fill={fill} | |
| stroke={stroke} | |
| strokeWidth={strokeWidth} | |
| strokeLinejoin="round" | |
| filter={filter} | |
| > | |
| {TEXT} | |
| </SvgText> | |
| ) | |
| } | |
| function Silhouette() { | |
| return ( | |
| <View style={styles.stage}> | |
| <Svg width={STAGE} height={STAGE}> | |
| <Word fill="#fff" stroke="#fff" strokeWidth={HALO_WIDTH + RIM_WIDTH * 2} /> | |
| </Svg> | |
| </View> | |
| ) | |
| } | |
| function Sticker() { | |
| const peel = useSharedValue(INITIAL_PEEL) | |
| const peelDir = useSharedValue(180) | |
| const textW = useSharedValue(0) | |
| const textH = useSharedValue(0) | |
| const grab = useSharedValue<Grab | null>(null) | |
| const fold = useDerivedValue(() => computeFold(peel.value, peelDir.value), [peel, peelDir]) | |
| const baseClip = useAnimatedStyle( | |
| () => ({ transform: [{ matrix: clipperMatrix(fold.value, true) }] }), | |
| [fold] | |
| ) | |
| const baseInner = useAnimatedStyle( | |
| () => ({ transform: [{ matrix: clipperInnerMatrix(fold.value, true) }] }), | |
| [fold] | |
| ) | |
| const flapRoot = useAnimatedStyle( | |
| () => ({ transform: [{ matrix: mat4(...fold.value.flap) }] }), | |
| [fold] | |
| ) | |
| const flapClip = useAnimatedStyle( | |
| () => ({ transform: [{ matrix: clipperMatrix(fold.value, false) }] }), | |
| [fold] | |
| ) | |
| const flapInner = useAnimatedStyle( | |
| () => ({ transform: [{ matrix: clipperInnerMatrix(fold.value, false) }] }), | |
| [fold] | |
| ) | |
| const flapTint = useAnimatedStyle( | |
| () => ({ transform: [{ matrix: bandMatrix(fold.value, fold.value.flapSpan, 1) }] }), | |
| [fold] | |
| ) | |
| const tube = useAnimatedStyle( | |
| () => ({ | |
| transform: [ | |
| { matrix: bandMatrix(fold.value, clamp(fold.value.flapSpan * 0.55, 40, 150), 1) }, | |
| ], | |
| }), | |
| [fold] | |
| ) | |
| const contact = useAnimatedStyle( | |
| () => ({ | |
| opacity: peel.value > 0 ? 1 : 0, | |
| transform: [{ matrix: bandMatrix(fold.value, 8 + peel.value * 0.15, -1) }], | |
| }), | |
| [fold, peel] | |
| ) | |
| const shadow = useAnimatedStyle( | |
| () => ({ | |
| opacity: 0.2 + 0.12 * (peel.value / 100), | |
| transform: [{ translateX: 6 }, { translateY: 10 + 10 * (peel.value / 100) }], | |
| }), | |
| [peel] | |
| ) | |
| const crease = useAnimatedStyle(() => { | |
| const f = fold.value | |
| return { | |
| opacity: peel.value > 0 && peel.value < 100 ? 1 : 0, | |
| transform: [ | |
| { | |
| matrix: mat4( | |
| -f.sin, | |
| f.cos, | |
| -f.cos, | |
| -f.sin, | |
| f.creaseX + (f.sin * CREASE_LENGTH) / 2 + (f.cos * CREASE_THICKNESS) / 2, | |
| f.creaseY - (f.cos * CREASE_LENGTH) / 2 + (f.sin * CREASE_THICKNESS) / 2 | |
| ), | |
| }, | |
| ], | |
| } | |
| }, [fold, peel]) | |
| // The grabbed point rides with the finger, so the crease is the perpendicular | |
| // bisector of anchor -> finger and both peel and direction fall out of it. | |
| const gesture = useMemo( | |
| () => | |
| Gesture.Pan() | |
| .manualActivation(true) | |
| .onTouchesDown((e, mgr) => { | |
| if (grab.value) return | |
| const touch = e.allTouches[0] | |
| const target = | |
| touch && findGrab(touch.x, touch.y, peel.value, peelDir.value, textW.value, textH.value) | |
| if (!target) { | |
| mgr.fail() | |
| return | |
| } | |
| cancelAnimation(peel) | |
| grab.value = target | |
| mgr.activate() | |
| }) | |
| .onUpdate((e) => { | |
| const g = grab.value | |
| if (!g) return | |
| let vx = g.anchorX - e.x | |
| let vy = g.anchorY - e.y | |
| const back = vx * g.pullX + vy * g.pullY | |
| if (back < 0) { | |
| vx -= back * g.pullX | |
| vy -= back * g.pullY | |
| } | |
| const len = Math.hypot(vx, vy) | |
| if (len < 1) { | |
| peel.value = 0 | |
| return | |
| } | |
| const nx = vx / len | |
| const ny = vy / len | |
| const mid = g.anchorX * nx + g.anchorY * ny - len / 2 | |
| const { min, max } = stageSpan(nx, ny) | |
| // Unrounded: quantizing here makes the tip stutter on diagonal drags. | |
| peel.value = clamp(((max - mid) / (max - min)) * 100, 0, 100) | |
| peelDir.value = ((((Math.atan2(ny, nx) * 180) / Math.PI) % 360) + 360) % 360 | |
| }) | |
| .onFinalize(() => { | |
| if (!grab.value) return | |
| grab.value = null | |
| const from = peel.value | |
| if (from > 0) { | |
| peel.value = withTiming(0, { | |
| duration: 800 + from * 8, | |
| easing: Easing.inOut(Easing.cubic), | |
| }) | |
| } | |
| }), | |
| [peel, peelDir, textW, textH, grab] | |
| ) | |
| return ( | |
| <GestureDetector gesture={gesture}> | |
| <View style={styles.stage}> | |
| <Animated.View pointerEvents="none" style={[styles.clipper, baseClip]}> | |
| <Animated.View style={[styles.clipInner, baseInner]}> | |
| <Svg width={STAGE} height={STAGE} style={StyleSheet.absoluteFill}> | |
| <Word fill={RIM_COLOR} stroke={RIM_COLOR} strokeWidth={HALO_WIDTH + RIM_WIDTH * 2} /> | |
| <Word fill="#fff" stroke="#fff" strokeWidth={HALO_WIDTH} /> | |
| <Word fill={INK} /> | |
| </Svg> | |
| <MaskedView style={StyleSheet.absoluteFill} maskElement={<Silhouette />}> | |
| <Animated.View style={[styles.contactBand, contact]} /> | |
| </MaskedView> | |
| </Animated.View> | |
| </Animated.View> | |
| {/* Native clips after blurring, which would cut the shadow dead straight | |
| at the crease, so a gradient mask fades it out first. */} | |
| <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, shadow]}> | |
| <Animated.View style={[styles.flapRoot, flapRoot]}> | |
| <Animated.View style={[styles.clipper, flapClip]}> | |
| <MaskedView style={styles.clipperFill} maskElement={<View style={styles.fadeMask} />}> | |
| <Animated.View style={[styles.clipInner, flapInner]}> | |
| <Svg | |
| width={STAGE + SHADOW_PAD * 2} | |
| height={STAGE + SHADOW_PAD * 2} | |
| viewBox={`${-SHADOW_PAD} ${-SHADOW_PAD} ${STAGE + SHADOW_PAD * 2} ${STAGE + SHADOW_PAD * 2}`} | |
| style={styles.shadowSvg} | |
| > | |
| <Defs> | |
| <Filter id="peel-blur" x="-40%" y="-40%" width="180%" height="180%"> | |
| <FeGaussianBlur stdDeviation={14} /> | |
| </Filter> | |
| </Defs> | |
| <Word | |
| fill={SHADOW_COLOR} | |
| stroke={SHADOW_COLOR} | |
| strokeWidth={HALO_WIDTH} | |
| filter="url(#peel-blur)" | |
| /> | |
| </Svg> | |
| </Animated.View> | |
| </MaskedView> | |
| </Animated.View> | |
| </Animated.View> | |
| </Animated.View> | |
| <Animated.View pointerEvents="none" style={[styles.flapRoot, flapRoot]}> | |
| <Animated.View style={[styles.clipper, flapClip]}> | |
| <Animated.View style={[styles.clipInner, flapInner]}> | |
| <MaskedView style={StyleSheet.absoluteFill} maskElement={<Silhouette />}> | |
| <View style={styles.stage}> | |
| <Svg width={STAGE} height={STAGE} style={StyleSheet.absoluteFill}> | |
| <Word | |
| fill={RIM_COLOR} | |
| stroke={RIM_COLOR} | |
| strokeWidth={HALO_WIDTH + RIM_WIDTH * 2} | |
| /> | |
| <Word fill="#fff" stroke="#fff" strokeWidth={HALO_WIDTH} /> | |
| </Svg> | |
| <Animated.View style={[styles.tintBand, flapTint]} /> | |
| <Animated.View style={[styles.tubeBand, tube]} /> | |
| <Animated.View style={[styles.glintBand, tube]} /> | |
| </View> | |
| </MaskedView> | |
| </Animated.View> | |
| </Animated.View> | |
| </Animated.View> | |
| <MaskedView | |
| pointerEvents="none" | |
| style={StyleSheet.absoluteFill} | |
| maskElement={<Silhouette />} | |
| > | |
| <Animated.View style={[styles.creaseLine, crease]} /> | |
| </MaskedView> | |
| <View pointerEvents="none" style={styles.measureWrap}> | |
| <Text | |
| numberOfLines={1} | |
| onLayout={(e) => { | |
| textW.value = e.nativeEvent.layout.width | |
| textH.value = e.nativeEvent.layout.height | |
| }} | |
| style={styles.measureText} | |
| > | |
| {TEXT} | |
| </Text> | |
| </View> | |
| </View> | |
| </GestureDetector> | |
| ) | |
| } | |
| export default function PillScreen() { | |
| const { width } = useWindowDimensions() | |
| const scale = Math.min(1, (width - 48) / STAGE) | |
| return ( | |
| <View style={styles.page}> | |
| <Stack.Screen | |
| options={{ | |
| title: '', | |
| headerTransparent: true, | |
| headerShadowVisible: false, | |
| headerBlurEffect: 'none', | |
| headerStyle: { backgroundColor: 'transparent' }, | |
| headerBackground: () => null, | |
| }} | |
| /> | |
| <View style={{ width: STAGE * scale, height: STAGE * scale }}> | |
| <View style={[styles.scaleBox, { transform: [{ scale }] }]}> | |
| <Sticker /> | |
| </View> | |
| </View> | |
| </View> | |
| ) | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // Styles | |
| // ----------------------------------------------------------------------------- | |
| const anchored = { position: 'absolute', left: 0, top: 0, transformOrigin: 'top left' } as const | |
| const band = { ...anchored, width: STAGE, height: CLIP } as const | |
| const styles = StyleSheet.create({ | |
| page: { | |
| flex: 1, | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| padding: 24, | |
| experimental_backgroundImage: 'linear-gradient(180deg, #ffffff 0%, #eef0f3 55%, #e3e6ea 100%)', | |
| }, | |
| stage: { | |
| width: STAGE, | |
| height: STAGE, | |
| }, | |
| scaleBox: { | |
| width: STAGE, | |
| height: STAGE, | |
| transformOrigin: 'top left', | |
| }, | |
| clipper: { | |
| ...anchored, | |
| width: CLIP, | |
| height: CLIP, | |
| overflow: 'hidden', | |
| }, | |
| clipInner: { | |
| ...anchored, | |
| width: STAGE, | |
| height: STAGE, | |
| }, | |
| flapRoot: { | |
| ...anchored, | |
| width: STAGE, | |
| height: STAGE, | |
| }, | |
| clipperFill: { | |
| width: CLIP, | |
| height: CLIP, | |
| }, | |
| fadeMask: { | |
| width: CLIP, | |
| height: CLIP, | |
| experimental_backgroundImage: | |
| 'linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 4%, rgba(0,0,0,1) 100%)', | |
| }, | |
| shadowSvg: { | |
| position: 'absolute', | |
| left: -SHADOW_PAD, | |
| top: -SHADOW_PAD, | |
| }, | |
| tintBand: { | |
| ...band, | |
| experimental_backgroundImage: `linear-gradient(90deg, rgba(${SHADE},0) 0%, rgba(${SHADE},0.02) 30%, rgba(${SHADE},0.05) 70%, rgba(${SHADE},0.09) 100%)`, | |
| }, | |
| tubeBand: { | |
| ...band, | |
| experimental_backgroundImage: `linear-gradient(90deg, rgba(${SHADE},0.5) 0%, rgba(${SHADE},0.26) 8%, rgba(${SHADE},0.05) 17%, rgba(${SHADE},0) 26%, rgba(${SHADE},0.14) 48%, rgba(${SHADE},0.06) 74%, rgba(${SHADE},0) 100%)`, | |
| }, | |
| glintBand: { | |
| ...band, | |
| experimental_backgroundImage: | |
| 'linear-gradient(90deg, rgba(255,255,255,0) 12%, rgba(255,255,255,0.9) 24%, rgba(255,255,255,0.55) 33%, rgba(255,255,255,0) 46%, rgba(255,255,255,0) 100%)', | |
| }, | |
| // Plain alpha rather than mixBlendMode multiply: inside a MaskedView iOS | |
| // blends against a transparent backdrop, which paints this as a light band. | |
| contactBand: { | |
| ...band, | |
| experimental_backgroundImage: `linear-gradient(90deg, rgba(${SHADE},0.3) 0%, rgba(${SHADE},0.08) 60%, rgba(${SHADE},0) 100%)`, | |
| }, | |
| creaseLine: { | |
| ...anchored, | |
| width: CREASE_LENGTH, | |
| height: CREASE_THICKNESS, | |
| experimental_backgroundImage: | |
| 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.95) 45%, rgba(255,255,255,0.95) 55%, rgba(255,255,255,0) 100%)', | |
| }, | |
| measureWrap: { | |
| position: 'absolute', | |
| left: 0, | |
| top: 0, | |
| right: 0, | |
| bottom: 0, | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| }, | |
| measureText: { | |
| opacity: 0, | |
| fontFamily: FONT, | |
| fontSize: FONT_SIZE, | |
| fontWeight: '900', | |
| letterSpacing: LETTER_SPACING, | |
| includeFontPadding: false, | |
| }, | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment