Skip to content

Instantly share code, notes, and snippets.

@byteab
Created July 24, 2026 23:46
Show Gist options
  • Select an option

  • Save byteab/a4c6efcebf1b73032ac88ee75092dbde to your computer and use it in GitHub Desktop.

Select an option

Save byteab/a4c6efcebf1b73032ac88ee75092dbde to your computer and use it in GitHub Desktop.
text peel effect in react native
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