Skip to content

Instantly share code, notes, and snippets.

@daehyeonmun2021
Last active November 24, 2025 09:03
Show Gist options
  • Select an option

  • Save daehyeonmun2021/fd036309b3fa964b0cc91ac3280da070 to your computer and use it in GitHub Desktop.

Select an option

Save daehyeonmun2021/fd036309b3fa964b0cc91ac3280da070 to your computer and use it in GitHub Desktop.
PageCurl - React Native Skia
import {
Easing,
interpolate,
useDerivedValue,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import {
PixelRatio,
Pressable,
StyleSheet,
StyleProp,
Text,
View,
ViewStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
Canvas,
Group,
Image,
Paint,
RuntimeShader,
Skia,
rect,
useImage,
vec,
} from '@shopify/react-native-skia';
import { scheduleOnUI } from 'react-native-worklets';
// ============================================================================
// Shader Utilities
// ============================================================================
type Value = string | number;
type Values = Value[];
/**
* Template tag for GLSL shader code with value interpolation
*/
const glsl = (source: TemplateStringsArray, ...values: Values) => {
const processed = source.flatMap((s, i) => [s, values[i]]).filter(Boolean);
return processed.join('');
};
/**
* Compiles a GLSL fragment shader into a Skia RuntimeEffect
* @throws Error if shader compilation fails
*/
const frag = (source: TemplateStringsArray, ...values: Values) => {
const code = glsl(source, ...values);
const rt = Skia.RuntimeEffect.Make(code);
if (rt === null) {
throw new Error("Couldn't Compile Shader");
}
return rt;
};
// ============================================================================
// Constants
// ============================================================================
const DPR = PixelRatio.get(); // Device pixel ratio for high-DPI displays
const WIDTH = 310;
const HEIGHT = 380;
const ANGLE = 5.89; // Rotation angle in degrees for visual effect
const IMAGE_RECT = rect(0, 0, WIDTH, HEIGHT);
const IMAGE_RECT_DPR = [IMAGE_RECT.width, IMAGE_RECT.height].map(
(v) => v * DPR,
);
// Animation configuration: curl animates from bottom-left (30%, 80%) to bottom-right (100%, 100%)
const ANIMATION = {
DURATION: 2000,
START_X: 0.3,
START_Y: 0.8,
END_X: 1,
END_Y: 1,
} as const;
// ============================================================================
// Page Curl Shader
// ============================================================================
/**
* Page Curl Shader Effect
*
* Simulates a realistic page curl by modeling the page as a cylinder.
* The shader divides the screen into three regions:
* 1. Beyond the curl (transparent/hidden)
* 2. The curl surface (cylindrical mapping with shadow)
* 3. Before the curl (normal flat page)
*
* Algorithm:
* - Find the "curl line" perpendicular to curl direction
* - Calculate each pixel's signed distance from the curl line
* - Map pixels onto a cylinder surface based on distance
* - Apply shadow gradient on the curled region
*
* Uniforms:
* u_image - Source texture to be curled
* u_resolution - Canvas resolution (width, height) in pixels
* u_originPos - Fixed corner where curl originates (typically bottom-right)
* u_currPos - Animated curl tip position
*/
const pageCurl = frag`
uniform shader u_image;
uniform vec2 u_resolution;
uniform vec2 u_originPos;
uniform vec2 u_currPos;
const float PI = 3.14159265359;
const float RADIUS = 0.1; // Cylinder radius in normalized space
const float SHADOW_EXPONENT = 0.2; // Controls shadow gradient falloff
vec4 main(vec2 fragCoord) {
// 1. Setup coordinate systems
float aspect = u_resolution.x / u_resolution.y;
vec2 aspectScale = vec2(aspect, 1.0); // Scale to square aspect ratio
vec2 uvScale = vec2(u_resolution.x / aspect, u_resolution.y); // For texture sampling
vec2 uv = fragCoord * aspectScale / u_resolution; // Normalized pixel coord [0,1]
// 2. Define curl geometry
vec2 curlTip = u_currPos * aspectScale / u_resolution; // Current curl tip position
vec2 curlDir = normalize(abs(u_originPos) - u_currPos); // Direction from origin to tip
// Find where the curl line intersects the left edge (y-axis)
// This is the "hinge" point where the curl begins
vec2 origin = clamp(
curlTip - curlDir * curlTip.x / curlDir.x,
0.0,
1.0
);
// 3. Calculate curl line length
// Base length from origin to tip, plus adjustment for canvas edge
float curlLength = clamp(
length(curlTip - origin) +
(aspect - (abs(u_originPos.x) / u_resolution.x) * aspect) / curlDir.x,
0.0,
aspect / curlDir.x
);
// Special case: leftward curls don't need edge adjustment
if (curlDir.x < 0.0) {
curlLength = distance(curlTip, origin);
}
// 4. Calculate pixel distance from curl line
// Project pixel onto curl direction, subtract curl length
// Positive = beyond curl, Negative = before curl, Near-zero = on curl surface
float dist = dot(uv - origin, curlDir) - curlLength;
// Find closest point on the curl line to current pixel
vec2 linePoint = uv - dist * curlDir;
// 5. Render based on distance from curl line
// REGION 1: Beyond the curl cylinder → fully transparent
if (dist > RADIUS) {
return vec4(0.0, 0.0, 0.0, 0.0);
}
// REGION 2: On the curl surface [0, RADIUS]
// Map flat coordinates onto cylinder using arc length
else if (dist >= 0.0) {
// Angle around the cylinder (from front to back)
float theta = asin(dist / RADIUS);
// Two possible texture coordinates: back side and front side of cylinder
vec2 backPoint = linePoint + curlDir * (PI - theta) * RADIUS;
vec2 frontPoint = linePoint + curlDir * theta * RADIUS;
// Use back side if it's within bounds, otherwise show front
bool isInBounds =
backPoint.x <= aspect && backPoint.y <= 1.0 &&
backPoint.x > 0.0 && backPoint.y > 0.0;
uv = isInBounds ? backPoint : frontPoint;
// Sample texture at the mapped position
vec4 fragColor = u_image.eval(uv * uvScale);
// Apply shadow gradient: darker near the curl edge (dist=RADIUS), lighter at center (dist=0)
float shadowFactor = pow(
clamp((RADIUS - dist) / RADIUS, 0.0, 1.0),
SHADOW_EXPONENT
);
fragColor.rgb *= shadowFactor;
return fragColor;
}
// REGION 3: Before the curl (dist < 0)
// Show flat page, but account for paper "consumed" by the cylinder
else {
// Offset by the cylinder's circumference (PI * RADIUS)
vec2 mappedPoint = linePoint + curlDir * (abs(dist) + PI * RADIUS);
// Use mapped point if valid, otherwise use original UV
bool isInBounds =
mappedPoint.x <= aspect && mappedPoint.y <= 1.0 &&
mappedPoint.x > 0.0 && mappedPoint.y > 0.0;
uv = isInBounds ? mappedPoint : uv;
return u_image.eval(uv * uvScale);
}
}
`;
// ============================================================================
// Components
// ============================================================================
type ControlButtonProps = {
label: string;
onPress: () => void;
style?: StyleProp<ViewStyle>;
};
const ControlButton = ({ label, onPress, style }: ControlButtonProps) => {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.buttonBase,
styles.buttonSolid,
styles.buttonShadow,
pressed && styles.buttonPressed,
style,
]}
>
<Text style={styles.buttonText}>{label}</Text>
</Pressable>
);
};
// ============================================================================
// Main Screen
// ============================================================================
export const PageCurlScreen = () => {
const image = useImage(
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1200&q=80',
);
const progress = useSharedValue(1); // 0 = start of curl, 1 = fully curled
// Shader uniforms updated on every frame
const uniforms = useDerivedValue(() => {
return {
u_resolution: IMAGE_RECT_DPR,
u_originPos: IMAGE_RECT_DPR, // Fixed bottom-right corner
u_currPos: vec(
// Interpolate curl tip position from start to end
interpolate(
progress.value,
[0, 1],
[
IMAGE_RECT.width * ANIMATION.START_X,
IMAGE_RECT.width * ANIMATION.END_X,
],
) * DPR,
interpolate(
progress.value,
[0, 1],
[
IMAGE_RECT.height * ANIMATION.START_Y,
IMAGE_RECT.height * ANIMATION.END_Y,
],
) * DPR,
),
};
}, []);
const play = () => {
scheduleOnUI(() => {
progress.value = 0; // Reset to start
progress.value = withTiming(1, {
duration: ANIMATION.DURATION,
easing: Easing.out(Easing.cubic), // Smooth deceleration
});
});
};
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Page Curl Shader</Text>
</View>
<View style={styles.previewBlock}>
<View style={styles.previewCard}>
{image && (
<Canvas
style={{
width: IMAGE_RECT.width,
height: IMAGE_RECT.height,
backgroundColor: '#F8F5EC',
transform: [{ rotateZ: `${ANGLE}deg` }],
}}
>
<Group transform={[{ scale: 1 / DPR }]}>
<Group
layer={
<Paint>
<RuntimeShader source={pageCurl} uniforms={uniforms} />
</Paint>
}
transform={[{ scale: DPR }]}
>
<Image image={image} rect={IMAGE_RECT} fit="cover" />
</Group>
</Group>
</Canvas>
)}
</View>
</View>
<View style={styles.controls}>
<ControlButton label="Run" onPress={play} />
</View>
</View>
</SafeAreaView>
);
};
// ============================================================================
// Styles
// ============================================================================
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8F5EC',
},
container: {
flex: 1,
alignItems: 'center',
paddingHorizontal: 24,
paddingTop: 48,
paddingBottom: 40,
},
header: {
alignItems: 'center',
marginBottom: 56,
},
title: {
fontSize: 32,
fontWeight: '800',
letterSpacing: -0.5,
},
previewBlock: {
alignItems: 'center',
marginBottom: 48,
},
previewCard: {
padding: 24,
borderRadius: 24,
},
controls: {
alignItems: 'center',
},
buttonBase: {
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 16,
minWidth: 180,
alignItems: 'center',
justifyContent: 'center',
},
buttonSolid: {
backgroundColor: '#6366F1',
},
buttonShadow: {
boxShadow: '6px 6px 12px rgba(0,0,0,0.3)',
},
buttonPressed: {
opacity: 0.85,
transform: [{ scale: 0.98 }],
},
buttonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.3,
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment