
React Native Skia
- 79 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with frontend development tasks during AI-assisted development.
About
react-native-skia is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- react-native-skia
- Frontend Development
- AI-coding skill
React Native Skia by the numbers
- 79 all-time installs (skills.sh)
- +4 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,119 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill react-native-skiaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 79 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with frontend development tasks during AI-assisted development.
Files
React Native Skia v2
When to use this skill
Use this skill for React Native or Expo work that depends on @shopify/react-native-skia, especially when the user wants a custom animated element, canvas-driven interaction, shader effect, exported graphic, or performance-sensitive motion system.
Reach for it when the request mentions:
- Skia, Canvas, shaders, RuntimeEffect, RuntimeShader, paths, pictures, atlas, Paragraph, snapshots, Skottie, video, CanvasKit, or headless rendering
- visual language such as glass, glow, blob, liquid, particle, shimmer, parallax, neon, morph, premium loader, hero background, or "make this feel expensive"
- frame-rate or interaction concerns such as 60 fps, 120 fps, GPU-friendly, stutter, jank, gesture-driven, or "keep this off the JS thread"
Do not use this skill for ordinary layout, forms, lists, settings screens, or standard view animation unless the user clearly wants a Skia or canvas-based solution.
Default workflow
1. Audit the repo first in existing projects.
- Run:
python3 scripts/audit_skia_repo.py --root . --format markdown- Read the findings before editing code.
2. Lock the real constraints up front.
- Platforms: iOS / Android / Web
- Desired feel: ambient, tactile, energetic, technical, playful, premium
- Interaction model: passive loop, tap, drag, pinch, scrub, scroll-linked
- Performance expectations: decorative only, hero element, many instances, always-on animation
- Asset needs: custom fonts, images, video, Lottie JSON, captured React Native views
- Accessibility: reduced motion, readable text, tappable overlays
3. If the aesthetic is underspecified, propose 2-3 directions before coding.
- Give distinct options with trade-offs such as:
- Retained polish: gradients, blur, layered shapes, safest and simplest
- Shader-led: most distinctive, best for backgrounds and procedural effects
- High-instance field: Atlas or Picture driven, best for particles / sprites / trails
- Then implement the best fit.
4. Choose the simplest architecture that satisfies the effect.
- Use
references/decision-tree.md. - Default to retained mode.
- Escalate to
Picture,Atlas, textures, shaders, or headless only when the workload actually needs it.
5. Implement a complete patch, not fragments.
- Update imports, assets, bootstrap code, and fallbacks together.
- Include null guards for async fonts, images, and video frames.
- If web matters, include CanvasKit bootstrapping as part of the change.
6. Review both polish and performance before finishing.
- Run the review checklist in
references/performance-playbook.mdandreferences/motion-design-playbook.md. - Mention platform caveats, reduced-motion behaviour, and testing steps.
Hard rules for expert Skia work
- Keep animation state on the UI thread with Reanimated shared or derived values whenever practical.
- Pass shared or derived values directly to Skia props. Do not wrap Skia nodes with
createAnimatedComponentoruseAnimatedPropsjust to animate them. - Avoid reading shared values on the JS thread during normal rendering logic.
- Prefer animating
transform, opacity, shader uniforms, and other non-layout properties over layout-affecting view changes. - Memoise gesture objects and frame callbacks in component code.
- Use retained mode by default. Use
Picturewhen the number of draw commands changes frame to frame. UseAtlaswhen many instances share the same texture. Use texture hooks when a pre-rendered result is reused often. - Keep business data, theme context, and layout decisions outside the canvas tree unless you explicitly re-inject context.
- Use
Paragraphfor wrapped or multi-style text. Load fonts explicitly when custom families matter. - Treat
Paragraph,Picture,Skottie, and SVG as special cases for effects: uselayerwhen you need blur, filters, or other paint effects on them. - Treat
useImage()returningnullas normal. Never assume the image is synchronously ready. - When capturing React Native content with
makeImageFromView, keepcollapsable={false}on the captured root view. - When using
RuntimeShaderas an image filter, account for pixel density and supersample if the result looks soft. - On web, gate rendering until CanvasKit has loaded. In Expo web, rerun
setup-skia-webafter Skia upgrades unless CanvasKit comes from a CDN. - Only use
androidWarmupfor static, fully opaque canvases. Avoid it for animated or translucent scenes. - Respect reduced motion. Offer a static or lighter fallback when the effect is decorative.
Motion-design heuristics
Use these rules unless the user asks for a very specific visual style:
- Anchor each scene with one stable focal layer. Let one hero motion lead, one secondary motion support, and keep the rest quiet.
- Put soft, large, low-frequency motion in the background and small, sharp highlights in the foreground.
- Phase-offset loops so everything does not crest at the same time.
- Keep blur and transparency doing compositional work, not hiding weak geometry.
- Place the brightest contrast near the point of action or label, not the canvas edge.
- Clamp gesture-driven motion and spring back to rest states.
- Prefer visuals that can degrade gracefully to a static frame.
See references/motion-design-playbook.md for deeper guidance and pattern recipes.
Deliverable expectations
When you produce code with this skill:
- provide a full runnable TSX component or a coherent repository patch
- explain why the chosen primitives and rendering mode fit the task
- call out the likely performance profile and any trade-offs
- mention web/native setup needs if relevant
- include reduced-motion behaviour for decorative or always-on motion
- when the request is broad, provide 2-3 concept options first and then implement the best one
Bundled resources
- Official doc distillation:
references/official-doc-notes.md - Architecture and primitive selection:
references/decision-tree.md - Motion direction and polish heuristics:
references/motion-design-playbook.md - Performance rules and anti-patterns:
references/performance-playbook.md - Symptom-to-fix guide:
references/debugging-matrix.md - Pattern cookbook:
references/animated-element-recipes.md - Eval workflow notes:
references/eval-strategy.md - Ready-to-adapt TSX templates:
assets/templates/ - Repo audit script:
scripts/audit_skia_repo.py - Trigger and output-quality evals:
evals/
Quick path by task
New animated decorative element
1. Read references/animated-element-recipes.md. 2. Pick a concept lane from references/motion-design-playbook.md. 3. Start from the closest file in assets/templates/. 4. Keep the structure retained-mode unless the workload clearly needs Picture, Atlas, or a shader.
Gesture-driven surface
1. Confirm react-native-gesture-handler and Reanimated are correctly installed. 2. Prefer shared values for transforms and memoised gestures. 3. If per-element hit-testing is needed, use overlay views that mirror the canvas transforms.
Performance tuning
1. Run scripts/audit_skia_repo.py. 2. Read references/performance-playbook.md. 3. Decide whether the real problem is JS-thread churn, too many draw nodes, wrong render mode, missing web bootstrap, or expensive filters. 4. Only escalate complexity when there is clear evidence.
Text or badge UI
1. Prefer Paragraph. 2. Load fonts explicitly if typography matters. 3. Keep text crisp; do not bury active text inside broad blur/filter layers.
Web blank screen or setup problems
1. Check web bootstrap and CanvasKit loading first. 2. Check setup-skia-web, Babel/worklets config, and version compatibility before rewriting components.
Sprite or particle fields
1. Use retained mode only if the structure is fixed and modest. 2. Use Atlas for many instances of the same texture. 3. Use Picture for dynamic command lists such as trails, generative art, or changing entity counts. 4. Use texture hooks if pre-rendering saves repeated work.
import React, { useEffect, useMemo } from "react";
import {
Blur,
Canvas,
Circle,
Fill,
Group,
LinearGradient,
Paint,
RoundedRect,
Skia,
vec,
} from "@shopify/react-native-skia";
import {
Easing,
useDerivedValue,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
const WIDTH = 320;
const HEIGHT = 200;
const RADIUS = 28;
export function AmbientGradientCard() {
const reduceMotion = useReducedMotion();
const progress = useSharedValue(0.35);
useEffect(() => {
if (reduceMotion) {
progress.value = 0.35;
return;
}
progress.value = withRepeat(
withTiming(1, {
duration: 5200,
easing: Easing.inOut(Easing.sin),
}),
-1,
true
);
}, [progress, reduceMotion]);
const orbAX = useDerivedValue(() => 70 + progress.value * 90);
const orbAY = useDerivedValue(() => 62 + Math.sin(progress.value * Math.PI) * 26);
const orbBX = useDerivedValue(() => 240 - progress.value * 80);
const orbBY = useDerivedValue(() => 138 - Math.cos(progress.value * Math.PI) * 22);
const orbCX = useDerivedValue(() => 180 + Math.sin(progress.value * Math.PI * 2) * 28);
const orbCY = useDerivedValue(() => 54 + Math.cos(progress.value * Math.PI * 2) * 18);
const clip = useMemo(
() => Skia.RRectXY(Skia.XYWHRect(0, 0, WIDTH, HEIGHT), RADIUS, RADIUS),
[]
);
return (
<Canvas style={{ width: WIDTH, height: HEIGHT }}>
<Group clip={clip}>
<Fill>
<LinearGradient
start={vec(0, 0)}
end={vec(WIDTH, HEIGHT)}
colors={["#07101F", "#11284C", "#090F1C"]}
/>
</Fill>
<Group layer={<Paint><Blur blur={38} /></Paint>}>
<Circle cx={orbAX} cy={orbAY} r={74} color="rgba(96, 165, 250, 0.55)" />
<Circle cx={orbBX} cy={orbBY} r={78} color="rgba(168, 85, 247, 0.40)" />
<Circle cx={orbCX} cy={orbCY} r={52} color="rgba(34, 211, 238, 0.32)" />
</Group>
<RoundedRect
x={0}
y={0}
width={WIDTH}
height={HEIGHT}
r={RADIUS}
color="rgba(255,255,255,0.035)"
/>
<RoundedRect
x={1}
y={1}
width={WIDTH - 2}
height={HEIGHT - 2}
r={RADIUS - 1}
style="stroke"
strokeWidth={1}
color="rgba(255,255,255,0.12)"
/>
<RoundedRect
x={18}
y={18}
width={132}
height={18}
r={9}
color="rgba(255,255,255,0.18)"
/>
<RoundedRect
x={18}
y={48}
width={182}
height={12}
r={6}
color="rgba(255,255,255,0.12)"
/>
<RoundedRect
x={18}
y={68}
width={148}
height={12}
r={6}
color="rgba(255,255,255,0.09)"
/>
<RoundedRect
x={18}
y={HEIGHT - 54}
width={110}
height={30}
r={15}
color="rgba(255,255,255,0.10)"
/>
</Group>
</Canvas>
);
}
import React, { useMemo } from "react";
import {
Canvas,
Paragraph,
RoundedRect,
Skia,
TextAlign,
useFonts,
} from "@shopify/react-native-skia";
type CustomFontParagraphBadgeProps = {
fontFamily: string;
fontFiles: Record<string, number[]>;
title?: string;
body?: string;
width?: number;
};
/**
* Example:
*
* <CustomFontParagraphBadge
* fontFamily="Inter"
* fontFiles={{
* Inter: [
* require("../assets/fonts/Inter-Regular.ttf"),
* require("../assets/fonts/Inter-SemiBold.ttf"),
* ],
* }}
* />
*/
export function CustomFontParagraphBadge({
fontFamily,
fontFiles,
title = "Skia badge",
body = "Wrapped, multi-style text with an explicit custom font family.",
width = 300,
}: CustomFontParagraphBadgeProps) {
const fontManager = useFonts(fontFiles);
const paragraph = useMemo(() => {
if (!fontManager) {
return null;
}
const contentWidth = width - 32;
const builder = Skia.ParagraphBuilder.Make(
{ textAlign: TextAlign.Left, maxLines: 3 },
fontManager
);
builder
.pushStyle({
color: Skia.Color("#F8FAFC"),
fontFamilies: [fontFamily],
fontSize: 18,
fontStyle: { weight: 500 },
})
.addText(`${title}\n`)
.pop()
.pushStyle({
color: Skia.Color("#CBD5E1"),
fontFamilies: [fontFamily],
fontSize: 13,
heightMultiplier: 1.2,
})
.addText(body)
.pop();
const result = builder.build();
result.layout(contentWidth);
return result;
}, [body, fontFamily, fontManager, title, width]);
if (!paragraph) {
return null;
}
return (
<Canvas style={{ width, height: 120 }}>
<RoundedRect x={0} y={0} width={width} height={120} r={22} color="#0F172A" />
<RoundedRect
x={1}
y={1}
width={width - 2}
height={118}
r={21}
style="stroke"
strokeWidth={1}
color="rgba(255,255,255,0.10)"
/>
<Paragraph paragraph={paragraph} x={16} y={16} width={width - 32} />
</Canvas>
);
}
import React, { useEffect } from "react";
import {
Blur,
Canvas,
Fill,
Group,
LinearGradient,
Paint,
Path,
Skia,
usePathInterpolation,
vec,
} from "@shopify/react-native-skia";
import {
Easing,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
const WIDTH = 280;
const HEIGHT = 280;
const PATH_A = Skia.Path.MakeFromSVGString(
"M140 26C183 26 223 57 234 103C246 153 220 214 170 241C118 269 58 249 33 203C7 155 19 95 58 58C80 37 109 26 140 26Z"
)!;
const PATH_B = Skia.Path.MakeFromSVGString(
"M143 26C191 31 231 66 239 113C246 156 221 215 171 242C120 270 58 250 31 201C9 160 19 104 51 66C73 40 106 22 143 26Z"
)!;
const PATH_C = Skia.Path.MakeFromSVGString(
"M139 29C189 24 232 63 241 111C250 156 218 219 165 244C109 270 44 242 26 190C11 148 31 102 64 65C83 43 110 32 139 29Z"
)!;
export function MorphingBlob() {
const reduceMotion = useReducedMotion();
const progress = useSharedValue(0.4);
useEffect(() => {
if (reduceMotion) {
progress.value = 0.5;
return;
}
progress.value = withRepeat(
withTiming(2, {
duration: 4200,
easing: Easing.inOut(Easing.cubic),
}),
-1,
true
);
}, [progress, reduceMotion]);
const path = usePathInterpolation(progress, [0, 1, 2], [PATH_A, PATH_B, PATH_C]);
return (
<Canvas style={{ width: WIDTH, height: HEIGHT }}>
<Fill color="#040816" />
<Group transform={[{ translateX: 6 }, { translateY: 8 }]}>
<Group layer={<Paint><Blur blur={24} /></Paint>}>
<Path path={path} color="rgba(96,165,250,0.28)" />
</Group>
<Path path={path}>
<LinearGradient
start={vec(40, 30)}
end={vec(220, 250)}
colors={["#60A5FA", "#8B5CF6", "#22D3EE"]}
/>
</Path>
</Group>
</Canvas>
);
}
import React, { useMemo } from "react";
import { PixelRatio } from "react-native";
import { Canvas, Group, Image, useImage, vec } from "@shopify/react-native-skia";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { clamp, useSharedValue } from "react-native-reanimated";
type PanZoomImageStageProps = {
source: number | string;
width?: number;
height?: number;
minScale?: number;
maxScale?: number;
};
export function PanZoomImageStage({
source,
width = 320,
height = 220,
minScale = 1,
maxScale = 4,
}: PanZoomImageStageProps) {
const image = useImage(source);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const scale = useSharedValue(1);
const baseScale = useSharedValue(1);
const pan = useMemo(
() =>
Gesture.Pan().onChange((event) => {
translateX.value += event.changeX;
translateY.value += event.changeY;
}),
[translateX, translateY]
);
const pinch = useMemo(
() =>
Gesture.Pinch()
.onChange((event) => {
scale.value = clamp(baseScale.value * event.scale, minScale, maxScale);
})
.onEnd(() => {
baseScale.value = scale.value;
}),
[baseScale, maxScale, minScale, scale]
);
const gesture = useMemo(() => Gesture.Simultaneous(pan, pinch), [pan, pinch]);
if (!image) {
return null;
}
const pd = PixelRatio.get();
const imageWidth = image.width() / pd;
const imageHeight = image.height() / pd;
return (
<GestureDetector gesture={gesture}>
<Canvas style={{ width, height }}>
<Group
origin={vec(width / 2, height / 2)}
transform={[
{ translateX },
{ translateY },
{ scale },
]}
>
<Image
image={image}
x={(width - imageWidth) / 2}
y={(height - imageHeight) / 2}
width={imageWidth}
height={imageHeight}
fit="cover"
/>
</Group>
</Canvas>
</GestureDetector>
);
}
import React, { useEffect, useMemo } from "react";
import {
Canvas,
Circle,
Path,
Skia,
SweepGradient,
vec,
} from "@shopify/react-native-skia";
import {
useReducedMotion,
useSharedValue,
withTiming,
} from "react-native-reanimated";
type ProgressRingProps = {
progress?: number; // 0..1
size?: number;
strokeWidth?: number;
};
export function ProgressRing({
progress = 0.72,
size = 220,
strokeWidth = 18,
}: ProgressRingProps) {
const reduceMotion = useReducedMotion();
const end = useSharedValue(0);
const radius = (size - strokeWidth) / 2;
useEffect(() => {
const clamped = Math.max(0, Math.min(1, progress));
end.value = reduceMotion
? clamped
: withTiming(clamped, { duration: 900 });
}, [end, progress, reduceMotion]);
const path = useMemo(() => {
const ring = Skia.Path.Make();
ring.addCircle(size / 2, size / 2, radius);
return ring;
}, [radius, size]);
return (
<Canvas style={{ width: size, height: size }}>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
style="stroke"
strokeWidth={strokeWidth}
color="rgba(148, 163, 184, 0.18)"
/>
<Path
path={path}
style="stroke"
strokeWidth={strokeWidth}
strokeCap="round"
start={0}
end={end}
>
<SweepGradient
c={vec(size / 2, size / 2)}
colors={["#22D3EE", "#60A5FA", "#8B5CF6", "#22D3EE"]}
/>
</Path>
</Canvas>
);
}
import React from "react";
import {
Canvas,
Fill,
Shader,
Skia,
useClock,
vec,
} from "@shopify/react-native-skia";
import { useDerivedValue } from "react-native-reanimated";
const WIDTH = 320;
const HEIGHT = 220;
const source = Skia.RuntimeEffect.Make(`
uniform float2 resolution;
uniform float t;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
vec4 main(vec2 xy) {
vec2 uv = xy / resolution;
float n = noise(uv * 5.0 + vec2(t * 0.08, -t * 0.05));
float glow = 0.55 + 0.45 * sin((uv.x * 6.0) + t * 0.8);
vec3 base = mix(vec3(0.03, 0.07, 0.16), vec3(0.15, 0.39, 0.95), uv.y);
vec3 accent = mix(vec3(0.13, 0.82, 0.93), vec3(0.58, 0.36, 0.95), glow);
vec3 color = mix(base, accent, 0.22 + n * 0.25);
return vec4(color, 1.0);
}
`);
if (!source) {
throw new Error("Failed to compile shader source.");
}
export function ShaderNoiseBackground() {
const clock = useClock();
const uniforms = useDerivedValue(() => ({
resolution: vec(WIDTH, HEIGHT),
t: clock.value / 1000,
}));
return (
<Canvas style={{ width: WIDTH, height: HEIGHT }}>
<Fill>
<Shader source={source} uniforms={uniforms} />
</Fill>
</Canvas>
);
}
import React, { useEffect, useMemo } from "react";
import {
Canvas,
Fill,
Group,
LinearGradient,
Paragraph,
Rect,
RoundedRect,
Skia,
TextAlign,
vec,
} from "@shopify/react-native-skia";
import {
Easing,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
const WIDTH = 280;
const HEIGHT = 64;
const RADIUS = 20;
export function ShimmerCTAButton() {
const reduceMotion = useReducedMotion();
const shimmerX = useSharedValue(-100);
useEffect(() => {
if (reduceMotion) {
shimmerX.value = WIDTH * 0.4;
return;
}
shimmerX.value = withRepeat(
withTiming(WIDTH + 100, {
duration: 1800,
easing: Easing.linear,
}),
-1,
false
);
}, [reduceMotion, shimmerX]);
const clip = useMemo(
() => Skia.RRectXY(Skia.XYWHRect(0, 0, WIDTH, HEIGHT), RADIUS, RADIUS),
[]
);
const label = useMemo(() => {
const paragraph = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Center })
.pushStyle({
color: Skia.Color("#F8FAFC"),
fontSize: 18,
fontStyle: { weight: 500 },
})
.addText("Upgrade now")
.pop()
.build();
paragraph.layout(WIDTH);
return paragraph;
}, []);
return (
<Canvas style={{ width: WIDTH, height: HEIGHT }}>
<Group clip={clip}>
<Fill>
<LinearGradient
start={vec(0, 0)}
end={vec(WIDTH, HEIGHT)}
colors={["#1D4ED8", "#2563EB", "#7C3AED"]}
/>
</Fill>
<RoundedRect
x={0}
y={0}
width={WIDTH}
height={HEIGHT}
r={RADIUS}
color="rgba(255,255,255,0.04)"
/>
<Group transform={[{ translateX: shimmerX }, { rotate: -0.22 }]}>
<Rect x={0} y={-48} width={70} height={HEIGHT + 96}>
<LinearGradient
start={vec(0, 0)}
end={vec(70, 0)}
colors={[
"rgba(255,255,255,0.0)",
"rgba(255,255,255,0.28)",
"rgba(255,255,255,0.0)",
]}
/>
</Rect>
</Group>
<RoundedRect
x={1}
y={1}
width={WIDTH - 2}
height={HEIGHT - 2}
r={RADIUS - 1}
style="stroke"
strokeWidth={1}
color="rgba(255,255,255,0.18)"
/>
</Group>
<Paragraph paragraph={label} x={0} y={20} width={WIDTH} />
</Canvas>
);
}
import React, { useMemo } from "react";
import {
Blur,
Canvas,
Group,
Paint,
Skia,
Skottie,
useClock,
} from "@shopify/react-native-skia";
import { useDerivedValue, useReducedMotion } from "react-native-reanimated";
type SkottieLoaderProps = {
animationJson: Record<string, unknown>;
width?: number;
height?: number;
accentColor?: string;
};
/**
* Pass the parsed JSON object from a bundled Lottie file:
*
* const loaderJson = require("../assets/loader.json");
* <SkottieLoader animationJson={loaderJson} accentColor="#60A5FA" />
*/
export function SkottieLoader({
animationJson,
width = 220,
height = 220,
accentColor,
}: SkottieLoaderProps) {
const animation = useMemo(() => {
const value = Skia.Skottie.Make(JSON.stringify(animationJson));
if (!value) {
throw new Error("Failed to create Skottie animation from JSON.");
}
if (accentColor) {
const slotInfo = value.getSlotInfo();
if (slotInfo.colorSlotIDs.length > 0) {
value.setColorSlot(slotInfo.colorSlotIDs[0], Skia.Color(accentColor));
}
}
return value;
}, [accentColor, animationJson]);
const reduceMotion = useReducedMotion();
const clock = useClock();
const frame = useDerivedValue(() => {
if (reduceMotion) {
return 0;
}
const fps = animation.fps();
const duration = animation.duration();
return Math.floor((clock.value / 1000) * fps) % Math.max(1, duration * fps);
});
const size = animation.size();
const fitScale = Math.min(width / size.width, height / size.height);
const offsetX = (width - size.width * fitScale) / 2;
const offsetY = (height - size.height * fitScale) / 2;
return (
<Canvas style={{ width, height }}>
<Group
layer={
<Paint>
<Blur blur={4} />
</Paint>
}
transform={[
{ translateX: offsetX },
{ translateY: offsetY },
{ scale: fitScale },
]}
>
<Skottie animation={animation} frame={frame} />
</Group>
</Canvas>
);
}
import React, { useRef, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import {
Canvas,
Image,
RoundedRect,
type SkImage,
makeImageFromView,
} from "@shopify/react-native-skia";
export function SnapshotComposite() {
const ref = useRef<View>(null);
const [snapshot, setSnapshot] = useState<SkImage | null>(null);
const capture = async () => {
const image = await makeImageFromView(ref);
setSnapshot(image);
};
return (
<View>
<View ref={ref} collapsable={false} style={styles.card}>
<Text style={styles.title}>Snapshot this card</Text>
<Text style={styles.body}>
Capture a React Native subtree and then reuse it inside Skia.
</Text>
</View>
<Pressable onPress={capture} style={styles.button}>
<Text style={styles.buttonText}>Capture</Text>
</Pressable>
{snapshot ? (
<Canvas style={styles.canvas}>
<RoundedRect x={0} y={0} width={320} height={180} r={28} color="#0F172A" />
<RoundedRect
x={1}
y={1}
width={318}
height={178}
r={27}
style="stroke"
strokeWidth={1}
color="rgba(255,255,255,0.10)"
/>
<Image image={snapshot} x={16} y={16} width={288} height={148} fit="contain" />
</Canvas>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 24,
padding: 18,
backgroundColor: "#0F172A",
},
title: {
color: "#F8FAFC",
fontSize: 18,
fontWeight: "600",
},
body: {
color: "#CBD5E1",
fontSize: 14,
marginTop: 8,
lineHeight: 20,
},
button: {
marginTop: 12,
alignSelf: "flex-start",
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 10,
backgroundColor: "#1D4ED8",
},
buttonText: {
color: "#F8FAFC",
fontWeight: "600",
},
canvas: {
width: 320,
height: 180,
marginTop: 16,
},
});
import React, { useMemo } from "react";
import {
Atlas,
Canvas,
Circle,
Group,
useRSXformBuffer,
useTexture,
rect,
} from "@shopify/react-native-skia";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useReducedMotion, useSharedValue, withSpring } from "react-native-reanimated";
const WIDTH = 320;
const HEIGHT = 220;
const COUNT = 180;
const SPRITE = 18;
export function SpriteAtlasField() {
const reduceMotion = useReducedMotion();
const pointerX = useSharedValue(WIDTH / 2);
const pointerY = useSharedValue(HEIGHT / 2);
const texture = useTexture(
<Group>
<Circle cx={SPRITE / 2} cy={SPRITE / 2} r={SPRITE / 2 - 1} color="#22D3EE" />
<Circle cx={SPRITE / 2 - 2} cy={SPRITE / 2 - 2} r={SPRITE / 5} color="#F8FAFC" />
</Group>,
{ width: SPRITE, height: SPRITE }
);
const sprites = useMemo(
() => new Array(COUNT).fill(0).map(() => rect(0, 0, SPRITE, SPRITE)),
[]
);
const transforms = useRSXformBuffer(COUNT, (value, index) => {
"worklet";
const columns = 18;
const col = index % columns;
const row = Math.floor(index / columns);
const baseX = 8 + col * (SPRITE - 2);
const baseY = 10 + row * (SPRITE - 2);
const dx = pointerX.value - baseX;
const dy = pointerY.value - baseY;
const angle = Math.atan2(dy, dx);
const scale = reduceMotion ? 1 : 0.9 + ((index % 5) * 0.03);
value.set(scale * Math.cos(angle), scale * Math.sin(angle), baseX, baseY);
});
const gesture = useMemo(
() =>
Gesture.Pan()
.onChange((event) => {
pointerX.value = event.x;
pointerY.value = event.y;
})
.onFinalize(() => {
pointerX.value = withSpring(WIDTH / 2);
pointerY.value = withSpring(HEIGHT / 2);
}),
[pointerX, pointerY]
);
return (
<GestureDetector gesture={gesture}>
<Canvas style={{ width: WIDTH, height: HEIGHT }}>
<Atlas image={texture} sprites={sprites} transforms={transforms} />
</Canvas>
</GestureDetector>
);
}
Template index
These templates are starting points, not one-size-fits-all answers.
Visual surfaces
ambient-gradient-card.tsx— premium ambient card with blurred orbsshimmer-cta-button.tsx— clipped shimmer button with paragraph labelshader-noise-background.tsx— procedural shader backgroundmorphing-blob.tsx— path interpolation for organic motiontilt-spotlight-card.tsx— gesture-led spotlight / card depthvideo-frame-surface.tsx— video-backed hero / media surfaceskottie-loader.tsx— Lottie/Skottie playback with runtime tinting
Data / status / text
progress-ring.tsx— animated ring / HUD patterncustom-font-paragraph-badge.tsx— wrapped rich text with explicit font loading
Interaction / high-instance patterns
pan-zoom-image-stage.tsx— memoised pan + pinch image surfacesprite-atlas-field.tsx— repeated textured sprites viaAtlassnapshot-composite.tsx— capture React Native content into Skia
Adaptation advice
1. Preserve the architecture choice unless the workload changes. 2. Rename colours, dimensions, and copy to match the product. 3. If the user asks for "lighter" or "safer", remove secondary motion before rewriting the whole component. 4. If the user asks for "more premium", strengthen hierarchy and lighting before adding more moving parts.
import React, { useMemo } from "react";
import {
Canvas,
Fill,
Group,
LinearGradient,
RadialGradient,
Rect,
RoundedRect,
Skia,
vec,
} from "@shopify/react-native-skia";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import {
clamp,
useDerivedValue,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated";
const WIDTH = 320;
const HEIGHT = 200;
const RADIUS = 28;
export function TiltSpotlightCard() {
const reduceMotion = useReducedMotion();
const pointerX = useSharedValue(WIDTH / 2);
const pointerY = useSharedValue(HEIGHT / 2);
const tilt = useSharedValue(0);
const spotlightCenter = useDerivedValue(() => ({
x: pointerX.value,
y: pointerY.value,
}));
const clip = useMemo(
() => Skia.RRectXY(Skia.XYWHRect(0, 0, WIDTH, HEIGHT), RADIUS, RADIUS),
[]
);
const gesture = useMemo(
() =>
Gesture.Pan()
.onChange((event) => {
if (reduceMotion) {
return;
}
pointerX.value = clamp(event.x, 40, WIDTH - 40);
pointerY.value = clamp(event.y, 40, HEIGHT - 40);
tilt.value = clamp((event.x - WIDTH / 2) / WIDTH, -0.12, 0.12);
})
.onFinalize(() => {
pointerX.value = withSpring(WIDTH / 2);
pointerY.value = withSpring(HEIGHT / 2);
tilt.value = withSpring(0);
}),
[pointerX, pointerY, reduceMotion, tilt]
);
return (
<GestureDetector gesture={gesture}>
<Canvas style={{ width: WIDTH, height: HEIGHT }}>
<Group
clip={clip}
origin={vec(WIDTH / 2, HEIGHT / 2)}
transform={[{ rotate: tilt }]}
>
<Fill>
<LinearGradient
start={vec(0, 0)}
end={vec(WIDTH, HEIGHT)}
colors={["#07101F", "#1E3A8A", "#0B1220"]}
/>
</Fill>
<Rect x={0} y={0} width={WIDTH} height={HEIGHT}>
<RadialGradient
c={spotlightCenter}
r={86}
colors={[
"rgba(255,255,255,0.24)",
"rgba(255,255,255,0.08)",
"rgba(255,255,255,0.0)",
]}
/>
</Rect>
<RoundedRect
x={18}
y={18}
width={122}
height={18}
r={9}
color="rgba(255,255,255,0.18)"
/>
<RoundedRect
x={18}
y={48}
width={166}
height={12}
r={6}
color="rgba(255,255,255,0.12)"
/>
<RoundedRect
x={18}
y={68}
width={132}
height={12}
r={6}
color="rgba(255,255,255,0.08)"
/>
<RoundedRect
x={1}
y={1}
width={WIDTH - 2}
height={HEIGHT - 2}
r={RADIUS - 1}
style="stroke"
strokeWidth={1}
color="rgba(255,255,255,0.18)"
/>
</Group>
</Canvas>
</GestureDetector>
);
}
import React from "react";
import { Pressable } from "react-native";
import {
Canvas,
ColorMatrix,
Fill,
ImageShader,
RoundedRect,
useVideo,
} from "@shopify/react-native-skia";
import { useSharedValue } from "react-native-reanimated";
type VideoFrameSurfaceProps = {
source: string | number;
width?: number;
height?: number;
pausedInitially?: boolean;
};
/**
* Notes:
* - React Native Skia video needs Reanimated v3+.
* - Android video support requires API 26+.
* - For Expo assets, resolve the bundled URI first if needed.
*/
export function VideoFrameSurface({
source,
width = 320,
height = 220,
pausedInitially = false,
}: VideoFrameSurfaceProps) {
const paused = useSharedValue(pausedInitially);
const { currentFrame } = useVideo(source, {
paused,
looping: true,
});
return (
<Pressable
onPress={() => {
paused.value = !paused.value;
}}
style={{ width, height }}
>
<Canvas style={{ width, height }}>
{currentFrame ? (
<Fill>
<ImageShader
image={currentFrame}
x={0}
y={0}
width={width}
height={height}
fit="cover"
/>
<ColorMatrix
matrix={[
0.95, 0, 0, 0, 0.05,
0.70, 0, 0, 0, 0.12,
0.18, 0, 0, 0, 0.42,
0, 0, 0, 1, 0,
]}
/>
</Fill>
) : null}
<RoundedRect
x={1}
y={1}
width={width - 2}
height={height - 2}
r={26}
style="stroke"
strokeWidth={1}
color="rgba(255,255,255,0.14)"
/>
</Canvas>
</Pressable>
);
}
{
"skill_name": "react-native-skia",
"evals": [
{
"id": 1,
"prompt": "Create a premium React Native Skia card component for an upsell panel. I want layered glow, a restrained glass feel, and smooth always-on motion that still has a reduced-motion fallback.",
"expected_output": "A complete TSX component or coherent patch using React Native Skia with a clear motion concept, direct Reanimated shared or derived values on Skia props, and a reduced-motion path.",
"assertions": [
"The answer provides a full TSX component or a coherent patch",
"The code imports from @shopify/react-native-skia",
"The solution uses Reanimated shared or derived values directly on Skia props",
"The solution includes a reduced-motion fallback or lighter path",
"The explanation mentions why retained mode is sufficient or why a more advanced mode is needed"
]
},
{
"id": 2,
"prompt": "Build a morphing blob loader in React Native Skia. It should feel organic, not generic, and it needs to stay performant.",
"expected_output": "A full example using Path and path interpolation or another justified morphing strategy, plus a short explanation of why the chosen path animation approach fits the workload.",
"assertions": [
"The code uses Path or an equivalent vector geometry primitive",
"The answer uses a morphing approach that is appropriate for Skia, such as usePathInterpolation or a clearly justified alternative",
"The explanation mentions why the geometry is efficient enough for the chosen effect",
"The answer avoids React state as the primary per-frame animation driver"
]
},
{
"id": 3,
"prompt": "My Expo web app shows a blank screen after I added @shopify/react-native-skia and moved a Skia component into a route. Diagnose it and patch the bootstrap code.",
"expected_output": "A diagnosis grounded in Skia web startup, plus a concrete patch using LoadSkiaWeb or WithSkiaWeb, and mention of setup-skia-web when relevant.",
"assertions": [
"The answer mentions CanvasKit loading or Skia web bootstrap ordering",
"The answer includes a concrete code patch or runnable example",
"The answer mentions setup-skia-web when discussing Expo web upgrades or setup",
"The answer avoids blaming component logic before checking configuration"
]
},
{
"id": 4,
"prompt": "I have hundreds of repeated sparkles orbiting in a React Native Skia hero background. Optimise it and explain the trade-offs.",
"expected_output": "A recommendation centred on Atlas when instances share the same texture, with a clear distinction from Picture for dynamic command lists and notes about keeping animation state off the JS thread.",
"assertions": [
"The answer recommends Atlas for many instances of the same texture when appropriate",
"The answer distinguishes Atlas from Picture based on workload shape",
"The answer mentions keeping animation state on the UI thread or avoiding JS-thread churn",
"The explanation includes at least one trade-off or caveat"
]
},
{
"id": 5,
"prompt": "Implement a custom-font promotional badge with wrapped text in React Native Skia and explain why Paragraph is the right API.",
"expected_output": "A full example that uses Paragraph, explicit font loading, a null guard while fonts load, and a short explanation of why Paragraph is preferred for wrapped or multi-style text.",
"assertions": [
"The code uses Paragraph",
"The code uses useFonts or another explicit custom-font loading path",
"The code guards against fonts not being ready yet",
"The explanation states why Paragraph is preferable for wrapped or multi-style text"
]
},
{
"id": 6,
"prompt": "Create a pan-and-pinch image stage in React Native Skia for a photo editor. Keep the gestures smooth and the architecture sensible.",
"expected_output": "A full example using react-native-gesture-handler and Reanimated shared values, with memoised gesture objects and transforms applied coherently to the Skia scene.",
"assertions": [
"The answer uses react-native-gesture-handler",
"The answer uses Reanimated shared values for transforms",
"The answer memoises gesture objects or explicitly discusses why gesture allocation is controlled",
"The answer avoids React state as the primary per-frame gesture state container"
]
},
{
"id": 7,
"prompt": "Review this approach: I wrapped Skia circles with createAnimatedComponent, useAnimatedProps, and React state updates every frame because I wanted a premium particle field. What would you change?",
"expected_output": "A critique that redirects the implementation toward direct shared-value props, a better workload-specific render mode such as Atlas or Picture when justified, and explicit performance reasoning.",
"assertions": [
"The answer flags createAnimatedComponent or useAnimatedProps as unnecessary for ordinary Skia prop animation",
"The answer flags React state per frame as a problem",
"The answer recommends a more appropriate workload-specific render mode if the particle field is large",
"The answer explains why the revised approach is more performant"
]
},
{
"id": 8,
"prompt": "I need to capture a normal React Native view and stylise it in Skia. Implement it safely and mention the main pitfall.",
"expected_output": "A full example using makeImageFromView, asynchronous capture handling, and a note about collapsable={false} on the captured root view.",
"assertions": [
"The code uses makeImageFromView",
"The answer handles the asynchronous capture result",
"The answer mentions collapsable={false} on the captured root",
"The answer provides a full example or coherent patch"
]
},
{
"id": 9,
"prompt": "Create a branded React Native Skia loader from Lottie JSON using Skottie. It should support runtime accent-colour customisation and a reduced-motion fallback.",
"expected_output": "A full example that uses Skia.Skottie.Make, renders with the Skottie component, drives frame playback sensibly, and mentions how runtime styling or slots are handled.",
"assertions": [
"The code uses Skia.Skottie.Make or another correct Skottie construction path",
"The code renders a Skottie component",
"The answer includes a frame value or playback strategy appropriate for Skottie",
"The answer includes reduced-motion behaviour or a static fallback",
"The explanation mentions runtime property or slot customisation, or explicitly explains why it is omitted"
]
},
{
"id": 10,
"prompt": "Build a React Native Skia hero surface that uses video frames inside the canvas, supports tap-to-pause, and explain the main platform constraints.",
"expected_output": "A full example using useVideo with currentFrame guards, canvas rendering, and an explanation of native/web constraints such as Android API level and Reanimated requirements.",
"assertions": [
"The code uses useVideo",
"The code handles currentFrame not being ready yet",
"The answer includes a pause or seek control path",
"The explanation mentions at least one platform constraint for Skia video support"
]
}
]
}
Review rubric
Use this rubric for human review or blind LLM comparison between skill versions.
1. Architecture choice
- Did the answer pick retained mode,
Picture,Atlas, textures,Paragraph, or shaders for the right reason? - Did it explain the trade-off clearly?
2. Thread discipline
- Are Reanimated shared or derived values used directly on Skia props?
- Is JS-thread churn avoided?
- Are gesture objects and heavy derived constructs memoised where appropriate?
3. Visual direction
- Does the element have a clear design concept?
- Is there a sensible motion hierarchy rather than many random moving parts?
- Does the output look intentional rather than like a generic demo?
4. Performance reasoning
- Does the answer discuss workload shape (fixed scene vs dynamic command list vs repeated texture)?
- Are big-ticket anti-patterns avoided?
- Is reduced motion handled for decorative or always-on effects?
5. Platform completeness
- Does the answer mention web bootstrap, native setup, or asset-loading caveats when relevant?
- Are async image/font/snapshot cases handled properly?
6. Delivery quality
- Is the code complete enough to run or patch in?
- Does the explanation say why the primitives were chosen?
- Would an engineer trust this as a starting point for production work?
[
{
"query": "Build a premium glassmorphism pricing card in Expo with React Native Skia and a subtle drifting glow.",
"should_trigger": true
},
{
"query": "My React Native Skia shader background looks too busy and janky. Can you redesign and optimise it?",
"should_trigger": true
},
{
"query": "I need a particle-like sparkle field in @shopify/react-native-skia that can handle hundreds of repeated elements.",
"should_trigger": true
},
{
"query": "Can you wire up CanvasKit correctly for my Expo web Skia route?",
"should_trigger": true
},
{
"query": "Create a wrapped promotional badge in Skia with a custom font and rich text styles.",
"should_trigger": true
},
{
"query": "Make a pan and pinch photo stage with gesture handler and Skia.",
"should_trigger": true
},
{
"query": "Please add a subtle shimmer CTA using React Native Skia, but keep it fast.",
"should_trigger": true
},
{
"query": "My Skia runtime shader filter is blurry on iPhone. Fix the crispness.",
"should_trigger": true
},
{
"query": "Build a normal React Native settings screen with switches and a save button.",
"should_trigger": false
},
{
"query": "Show me how to animate a plain React Native View with LayoutAnimation.",
"should_trigger": false
},
{
"query": "Write a Node script that uploads rows from a CSV into Postgres.",
"should_trigger": false
},
{
"query": "Optimise my CSS canvas animation on a web landing page.",
"should_trigger": false
},
{
"query": "Use Skottie in React Native Skia to build a branded loader and recolour it to our accent.",
"should_trigger": true
}
]
[
{
"query": "I want a fluid blob loader in React Native using Skia. Give me something polished and efficient.",
"should_trigger": true
},
{
"query": "Why is my Expo web build blank only after importing a Skia canvas component?",
"should_trigger": true
},
{
"query": "Use Skia to build a draggable spotlight card with a premium feel.",
"should_trigger": true
},
{
"query": "Optimise this repeated sprite field in Skia without sacrificing the look.",
"should_trigger": true
},
{
"query": "Can you fix my flexbox spacing in a plain Expo screen?",
"should_trigger": false
},
{
"query": "Help me write a Figma handoff checklist.",
"should_trigger": false
},
{
"query": "Improve my FlashList scrolling performance.",
"should_trigger": false
},
{
"query": "Make a CSS-only shimmer button for my website.",
"should_trigger": false
},
{
"query": "Create a tappable video-backed hero surface in Skia and keep it performant.",
"should_trigger": true
}
]
Animated element recipes
Use these as pattern shortcuts. Each recipe names the best-fit primitives, the performance profile, and the closest bundled template.
Premium ambient card
Best when:
- the user wants "premium", "glass", "expensive", or "hero card"
- text or UI sits on top of the animation
- motion should feel calm and continuous
Primitives:
- retained mode
- clipped
Fill+ gradient - blurred circles / shapes
- thin highlight stroke
Watch out for:
- too many blur layers
- losing foreground contrast
- over-animating the whole card
Template: assets/templates/ambient-gradient-card.tsx
Shimmer CTA
Best when:
- the element is a button or promo strip
- the user wants a "scan", "shine", or "luxury" motion accent
Primitives:
- retained mode
- clip
- moving gradient band
- crisp foreground label
Watch out for:
- shimmer band too wide
- loop too fast
- too much opacity in the highlight
Template: assets/templates/shimmer-cta-button.tsx
Morphing organic blob
Best when:
- the user wants fluid, blob, or liquid motion
- the geometry can be expressed as matched paths
Primitives:
PathusePathInterpolation- optional glow / soft shadow
Watch out for:
- non-interpolatable paths
- overly aggressive wobble
- too many simultaneous morphs
Template: assets/templates/morphing-blob.tsx
Progress ring / HUD
Best when:
- the user wants readable status or progress
- motion should support comprehension rather than dominate the scene
Primitives:
Path- trim animation
- restrained gradients or glow
Watch out for:
- too much blur near the reading surface
- overly decorative animation in data-first contexts
Template: assets/templates/progress-ring.tsx
Tilt spotlight card
Best when:
- the user wants a tactile interactive card
- subtle pointer-following or drag-following light is enough
Primitives:
- retained shapes
- memoised gestures
- radial gradient spotlight
- shared-value transforms
Watch out for:
- excessive tilt
- no rest state
- gesture math without clamps
Template: assets/templates/tilt-spotlight-card.tsx
Sprite field
Best when:
- there are many similar instances
- the user asks for sparks, particles, tiles, or repeated glyphs
Primitives:
AtlasuseTexture- worklet-driven transform buffers
Watch out for:
- drawing each instance as its own view
- generating lots of unique textures unnecessarily
Template: assets/templates/sprite-atlas-field.tsx
Shader background
Best when:
- the visual is primarily a procedural fill or atmospheric background
- the user wants a more distinctive "signature" effect
Primitives:
RuntimeEffectShader- animated uniforms
Watch out for:
- using shader complexity where retained shapes would do
- burying interactive UI under a loud shader
Template: assets/templates/shader-noise-background.tsx
Custom-font paragraph badge
Best when:
- typography matters
- the element includes multi-style or wrapped text
Primitives:
ParagraphuseFonts- explicit font-family mapping
Watch out for:
- assuming fonts are available synchronously
- using a simpler text node and then fighting layout
Template: assets/templates/custom-font-paragraph-badge.tsx
Pan / zoom image stage
Best when:
- the user wants a gesture-driven image surface
- the image should stay inside Skia for further compositing or filtering
Primitives:
useImage- memoised
Pan+Pinchgestures - group transforms
Watch out for:
- missing root gesture setup
- no scale clamps
- rebuilding image state via React state every frame
Template: assets/templates/pan-zoom-image-stage.tsx
Snapshot composite
Best when:
- the user wants a React Native view captured and then stylised in Skia
- mixed React Native layout + Skia compositing is needed
Primitives:
makeImageFromViewImage- ordinary React Native subtree with
collapsable={false}
Watch out for:
- missing
collapsable={false} - forgetting that the capture is async
Template: assets/templates/snapshot-composite.tsx
Video-backed hero surface
Best when:
- the user wants live motion texture rather than procedural motion
- a hero card, backdrop, or promo surface should use video frames inside Skia
Primitives:
useVideoImageorImageShader- optional colour grading or filters
Watch out for:
- forgetting that
currentFramecan benull - missing Android API 26 support on native
- using video where a lighter procedural background would be enough
Template: assets/templates/video-frame-surface.tsx
Skottie loader or branded motion mark
Best when:
- design already exists as Lottie / Bodymovin JSON
- the user wants a polished loader or branded animation with programmatic control
Primitives:
Skia.Skottie.Make()SkottieuseClock()+ derived frame values- optional slot or property overrides
Watch out for:
- assuming Skottie follows ordinary paint rules without
layer - ignoring reduced-motion fallbacks
- scaling the animation without checking its native size
Template: assets/templates/skottie-loader.tsx
Debugging matrix
Use this file when the repo already contains Skia code and something feels wrong.
Install / build
Symptom
Native build fails after installing Skia.
Likely causes
- Skia postinstall script never ran.
- Bun blocked untrusted postinstall scripts.
- Yarn Berry disabled scripts.
- App versions do not match the supported React / React Native range.
Fixes
- Check
package.json, lockfile, and package manager configuration. - For Bun, add
@shopify/react-native-skiatotrustedDependencies. - For Yarn Berry, make sure
enableScriptsis notfalse. - Verify React Native / React / Skia version compatibility.
- Reinstall dependencies and rebuild native targets.
Web blank screen
Likely causes
- CanvasKit not loaded before Skia code runs.
setup-skia-webnot rerun after an upgrade.- Skia components inside Expo Router are evaluated too early.
Fixes
- Use
WithSkiaWeborLoadSkiaWeb(). - For Expo web, rerun
setup-skia-web. - If needed, defer root registration or code-split the Skia route/component.
Gestures do not work
Likely causes
react-native-gesture-handlermissing or misconfigured.GestureHandlerRootViewmissing near the app root.- Gesture transforms and canvas transforms are out of sync.
Fixes
- Verify dependency installation.
- Ensure the app root is wrapped in
GestureHandlerRootView. - Keep transforms in shared values and apply the same model everywhere.
Animation stutters
Likely causes
- React state updates every frame.
- shared values read on the JS thread
- wrong render mode for the workload
- too many repeated shapes instead of
Atlasor textures
Fixes
- Move state to shared / derived values.
- Stop reading
.valueon the JS thread in normal render logic. - Switch to
AtlasorPicturewhen appropriate. - Collapse repeated transforms into parent groups where possible.
Visuals are blurry or soft
Likely causes
RuntimeShaderimage filter ignores pixel density.- text or image is being blurred unintentionally
- low-resolution source image is scaled too far
Fixes
- Supersample
RuntimeShaderimage filters using layer scaling. - Keep text crisp and in a foreground layer.
- Use better source assets or texture strategy.
Text will not wrap or style correctly
Likely causes
- wrong API for text layout
- fonts still loading
- paragraph layout never computed
Fixes
- Prefer
Paragraph. - Guard until the font manager exists.
- Call
layout(width)before measuring or drawing.
Paragraph / Picture / Skottie effects do not apply
Likely cause
Those components do not follow the same painting rules as normal shapes.
Fix
Apply effects through a parent Group using layer.
Snapshot crashes or returns the wrong view
Likely cause
The root of the captured React Native subtree was optimised away.
Fix
Set collapsable={false} on the captured root view.
Theme or context values disappear inside Canvas
Likely cause
Skia uses a separate React renderer.
Fix
Prepare the values before entering the canvas tree, or explicitly re-inject context using a bridge.
Rotation or transform math looks wrong
Likely causes
- degrees were used instead of radians
- origin assumed to be centre instead of top-left
Fixes
- convert to radians
- set
originexplicitly when centring transforms - prefer a single parent transform when possible
Reduced motion is ignored
Likely cause
Decorative loops were hard-coded with no fallback path.
Fix
Use useReducedMotion() and provide a lighter or static path.
Rendering and primitive decision tree
Use this file to decide whether Skia is appropriate and, if so, which architecture to use.
Step 1: Should this be Skia at all?
Choose ordinary React Native views when the task is mainly:
- forms, settings, lists, app chrome, or ordinary layout
- text-heavy UI without special visual treatment
- standard button presses or simple view transitions
Choose Skia when the value comes from:
- custom drawing
- shader or filter effects
- gesture-heavy canvas interaction
- exportable graphics or snapshots
- lots of animated visual elements
- exact visual parity across platforms
- a motion-heavy hero surface or decorative system
Step 2: Pick the rendering mode
Retained mode (default)
Choose retained mode when:
- the scene structure is mostly fixed
- you animate positions, opacity, radii, trims, colors, or uniforms
- the element behaves like UI, not a mini game engine
Good fits:
- cards
- loaders
- progress rings
- charts with stable geometry
- hero backgrounds with a small set of layered shapes
- gesture-driven panels where transforms change but draw-node count does not
Immediate mode with Picture
Choose Picture when:
- the number of draw commands changes every frame
- you need a variable trail, generative art, dynamic scribbles, or particle counts
- you would otherwise create and destroy many draw nodes on every frame
Good fits:
- trails
- changing particle counts
- brush strokes
- generative visuals
- dynamic debug overlays
Step 3: Pick the right primitive
Canvas + basic shapes
Use when:
- geometry is small and easy to reason about
- you can express the effect with
Circle,Rect,RoundedRect,Path,Fill, and gradients - you want the most maintainable solution
Path
Use when:
- you already have SVG path data
- you need trim animations or precise vector geometry
- you need morphing between matched path structures
Notes:
- Reuse SVG path data when available.
- For morphing, the paths must be interpolatable: same number and types of commands.
Paragraph
Use when:
- text wraps
- text mixes styles
- custom font families matter
- text metrics or layout width must be controlled
Avoid simpler text nodes when the request includes badges, hero headings, counters with styling, or wrapped labels.
Atlas
Use when:
- many instances share the same texture or sprite
- per-instance transforms vary
- you need hundreds of repeated glyphs, particles, tiles, or sparkles
Typical pattern:
1. create or load one texture 2. reuse it with Atlas 3. drive transforms with worklets or shared-value buffers
Texture hooks
Use when:
- the visual can be pre-rendered once and then reused as an image
- a procedural or composed element should be cached
- an existing image needs GPU upload before heavy reuse
Choose:
useTexture()for texture-from-React-elementuseImageAsTexture()for uploaded imagesusePictureAsTexture()for prebuilt picture data
Shader / RuntimeEffect
Use when:
- the effect is naturally procedural
- the element is mostly a fill or background
- the desired look comes from warping, noise, gradients, or texture sampling
Prefer this for:
- premium backgrounds
- noise overlays
- glows
- procedural liquid or plasma-style effects
RuntimeShader
Use when:
- you need a custom image filter over already-rendered pixels
- the effect must inspect and transform the current image content
Notes:
- This is more specialised than plain
Shader. - Remember supersampling when crispness matters.
makeImageFromView
Use when:
- you need to capture a React Native view tree and then composite or display it in Skia
- the result mixes normal React Native layout with Skia effects
useVideo
Use when:
- the element should use video frames inside Skia
- video is being filtered, masked, shaded, or composed inside a canvas
Skottie
Use when:
- the source is a Lottie / Bodymovin animation
- programmatic control over playback or slots is required
- design already exists in After Effects
Complexity ladder
Start low and escalate only when needed:
1. retained shapes + gradients 2. retained shapes + blur / masks / path trim 3. retained shapes + gesture transforms 4. retained shapes + shader fills 5. Picture for variable command lists 6. Atlas for many repeated instances 7. headless or offscreen textures for export / reuse
Common pairings
- Glass card: retained shapes + clip + blur + subtle gradient + optional spotlight gesture
- Shimmer CTA: retained clip + moving gradient band
- Morphing blob:
Path+usePathInterpolation - Particle field:
AtlasorPicture, depending on whether texture is shared - Image editor / viewer:
useImage()+ memoised gestures + group transforms - Rich badge:
Paragraph+ explicit fonts + restrained effects - Canvas over app UI: overlay real React Native views for taps and accessibility
Eval strategy for this skill
This skill ships with:
evals/evals.jsonfor output-quality checksevals/trigger-train.jsonfor description tuningevals/trigger-validation.jsonfor held-out trigger validationevals/rubric.mdfor human or LLM review
How to iterate
1. Run the trigger sets multiple times and compute trigger rates. 2. Improve the description using only failures from the train split. 3. Re-check on the validation split. 4. Run the output-quality evals with and without the skill (or against the previous skill version). 5. Use the rubric to compare visual polish, architecture choice, and performance reasoning, not just syntax.
What this skill should outperform
A strong run should consistently:
- choose retained mode,
Picture,Atlas, or shader approaches for the right reasons - keep Reanimated state on the UI thread
- avoid
createAnimatedComponent/useAnimatedPropsaround Skia nodes - remember web bootstrap and snapshot caveats
- address reduced motion for decorative or always-on motion
- produce visually directed output rather than generic examples
High-value failure patterns to watch
- returns valid Skia code but with no aesthetic concept
- uses the wrong render mode for the workload
- animates via React state or JS-thread churn
- forgets
GestureHandlerRootVieworLoadSkiaWeb - uses generic text nodes when
Paragraphis clearly the right API - answers "performance" questions without talking about render mode or workload shape
Motion-design playbook for "amazing but performant" Skia work
This file is intentionally opinionated. Use it when the user asks for something that should feel premium, distinctive, or visually "expensive".
Start by choosing a concept lane
Pick one primary lane before coding. Mixing three big ideas at once usually produces muddy visuals.
1. Premium glass
Mood:
- calm
- layered
- soft depth
- high-end product marketing
Ingredients:
- clipped background gradient
- 2-3 blurred colour orbs
- thin highlight border
- restrained motion with slow drift
- bright focal edge or specular strip
Best for:
- cards
- finance/productivity dashboards
- premium CTA surfaces
- hero modules behind text
2. Editorial gradient
Mood:
- expressive
- elegant
- less technical than neon
- easy to keep cheap
Ingredients:
- asymmetric gradients
- one slow moving highlight
- no more than one strong blur layer
- typography kept crisp in foreground
Best for:
- splash blocks
- onboarding
- section headers
- subscription / upsell panels
3. Organic liquid / blob
Mood:
- playful
- dynamic
- contemporary
Ingredients:
- matched paths or control-point modulation
- subtle wobble or breathing loop
- optional inner glow or soft shadow
- phase offsets between shape and highlight
Best for:
- loaders
- avatars
- ambient backgrounds
- touch-reactive hero surfaces
4. Technical HUD
Mood:
- precise
- data-rich
- instrument-panel feel
Ingredients:
- rings, arcs, ticks, paths, trim animations
- stronger contrast and crisper edges
- minimal blur
- predictable motion that supports reading
Best for:
- progress indicators
- health / sensor panels
- charts
- scanning or status UI
5. Particle / sprite field
Mood:
- energetic
- playful
- spatial
Ingredients:
- many repeated instances
- coherent flow field or pointer attraction
- one small repeated texture
- minimal per-instance complexity
Best for:
- interactive hero backgrounds
- sparkle / dust effects
- playful loaders
- maps or tile-like compositions
Core composition rules
Use one hero motion
Good animated elements usually have:
- one hero motion
- one supporting motion
- one still anchor
Example:
- hero motion: drifting orb
- support: moving highlight band
- anchor: crisp card edge + label
Keep the focal area readable
- Do not place the brightest highlight behind body text.
- Do not blur text unless the blur is explicitly part of the design and readability is preserved.
- Treat active labels and icons as foreground UI, not decoration.
Use blur as depth, not camouflage
Blur is strongest when it:
- separates back and front layers
- softens large background masses
- adds luminous bloom
Blur is weakest when it:
- replaces good geometry
- covers the entire frame evenly
- touches core text or action affordances
Use asymmetry
Symmetry often reads as synthetic and cheap once animated. Prefer:
- slightly offset orbs
- uneven timing
- gradients not centred by default
- highlights that track an interaction or bias toward a corner
Keep loops phase-shifted
If two animated layers use the same period, offset them or give them different amplitudes. Synchronous loops feel robotic.
Timing guidance
These are starting points, not hard rules.
- Ambient drift:
3000-8000 ms - Loader cycles:
900-1600 ms - Microinteraction emphasis:
160-280 ms - Spring-back after drag: fast enough to feel connected, soft enough to avoid snap
Prefer easing that matches the concept lane:
- premium glass: slow in-out
- liquid / blob: soft in-out or spring
- technical HUD: more linear, measured timing
- shimmer: constant travel, not bouncing
Gesture design rules
- Clamp all gesture-driven offsets.
- Use the gesture to modulate a stable scene, not completely rebuild it.
- The resting state should look intentional.
- Interactive depth should usually be subtle; avoid theatrical tilt unless explicitly requested.
Fallback tiers
Always know the lighter version.
Tier A: cheapest
- static gradient
- single subtle highlight
- no blur or one small blur
- reduced motion or low-end fallback
Tier B: standard premium
- retained-mode layered shapes
- slow shared-value animation
- restrained blur
- best default for most product UI
Tier C: signature effect
- shader, Atlas, or Picture
- higher implementation complexity
- reserve for hero surfaces or user-facing brand moments
When the user says "make it amazing"
Translate that into explicit design moves:
- add depth, not just more colour
- add a motion hierarchy, not just faster motion
- add a clear focal point
- add one signature flourish that can be explained in plain language
Good answer pattern:
1. offer 2-3 concept directions 2. explain the trade-offs 3. implement the strongest fit 4. mention one lighter fallback
Official doc notes for React Native Skia, Reanimated, and Gesture Handler
This file distils the official docs reviewed on 2026-03-31. Re-check version-sensitive items when internet access is available.
React Native Skia
Installation and compatibility
- Current installation docs require:
react-native@>=0.79react@>=19iOS 14+Android API 21+- Video support needs Android API 26+.
- For older apps (
react-native <= 0.78,react <= 18), stay on@shopify/react-native-skia@<=1.12.4. - The package uses a postinstall step to place prebuilt binaries in native build locations.
- Bun projects must trust
@shopify/react-native-skiaintrustedDependencies. - Yarn Berry must not disable scripts with
enableScripts: false. - Expo provides a
with-skiatemplate for new projects.
Canvas, renderers, and render modes
Canvasis the root drawing surface and can be styled like a regular React Native view.- Skia uses its own React renderer. React Native context does not flow into the drawing tree automatically.
- Prefer preparing theme, business data, and layout values outside the canvas tree, or explicitly re-inject context if needed.
onSizewrites canvas dimensions into a Reanimated shared value on changes.androidWarmupis opt-in and is intended for static, fully opaque drawings only.
Choosing retained vs immediate mode
- Retained mode is the default and is best when the scene structure is stable and only props animate.
- Immediate mode is powered by
Pictureand is better when the number of drawing commands changes on each frame. - Since both modes share the same
Canvas, you can combine them.
Animation and gestures
- React Native Skia integrates with Reanimated v3+.
- Shared and derived values can be passed directly to Skia props.
- You do not need
createAnimatedComponentoruseAnimatedPropsjust to animate Skia nodes. - Gesture integration is documented with
react-native-gesture-handler. - For tracking individual canvas elements, the docs recommend overlaying views that mirror the same transforms.
Geometry and drawing primitives
Pathis semantically identical to SVG path data and can be created from SVG strings or programmatically.- Path trimming uses
start/endvalues from0..1. - Stroke conversion on a path can fail for hairline paths.
Groupapplies shared paint, transforms, clipping, and layer effects to descendants.- Skia transforms differ from React Native view transforms in two ways:
- default origin is top-left
- rotations are in radians
zIndexis local to the parentGroup.
Text
- Prefer
Paragraphfor wrapped, multi-style, or dependable rich text layout. - Use
useFontsfor custom font loading when specific families are required. Paragraphdoes not follow the same painting rules as regular shapes; effects should be applied throughlayer.- On web, provide your own emoji font if you need coloured emoji in paragraphs.
Pictures, atlas, and textures
Pictureis a strong fit for dynamic command lists such as trails, procedural scenes, or variable particle counts.Atlasis the efficient choice for many instances of the same texture or sprite.- Atlas transforms can be animated with near-zero cost using worklets.
- Texture helpers:
useTexture()for a texture created from React elementsuseImageAsTexture()for uploading an image to the GPUusePictureAsTexture()for converting anSkPictureinto a reusable texture
Images, snapshots, and media
useImage()loads bundle assets, named bundle images, or network URLs.useImage()is asynchronous and returnsnulluntil ready.makeImageFromView()captures a React Native subtree into anSkImage.- The captured root should have
collapsable={false}to avoid crashes or wrong output. - Canvas refs can create snapshots with
makeImageSnapshot()ormakeImageSnapshotAsync(). useVideo()exposes current video frames as Skia images and works withImage,ImageShader, andAtlas.- Video is supported on web too.
Shaders and filters
Skia.RuntimeEffect.Make()compiles shader code.- Use
Shaderfor custom fills. - Use
RuntimeShaderwhen you need a custom image filter over already-drawn pixels. RuntimeShaderimage filters do not handle device pixel density automatically; supersample when crispness matters.
Web, headless, Skottie, and size
- Web support runs through CanvasKit WASM, loaded asynchronously.
- The current docs quote the CanvasKit WASM payload at about
2.9 MBgzipped. - For Expo web, rerun
setup-skia-webafter Skia upgrades unless CanvasKit is loaded from a CDN. - Load Skia before importing Skia-dependent web components via
WithSkiaWeborLoadSkiaWeb(). - Static web canvases can opt into
__destroyWebGLContextAfterRender={true}to release the WebGL context after drawing. - Headless mode runs on Node, defaults to CPU rendering, and can use GPU acceleration with an OffscreenCanvas polyfill.
- Skottie renders Lottie/Bodymovin animations and works smoothly with
useClock()+ Reanimated derived values. - Bundle-size guidance in the docs is order-of-magnitude:
- about
4 MBextra Android download size via App Bundles - about
6 MBextra iOS download size - about
2.9 MBgzipped CanvasKit on web
React Native Reanimated
Installation and setup
- Reanimated 4.x targets the New Architecture.
- Reanimated 4 requires
react-native-worklets. - In Community CLI apps,
react-native-worklets/pluginmust be added to Babel and listed last. - On web, if you are not using the Babel plugin, dependency arrays are required for hooks such as
useDerivedValue,useAnimatedStyle,useAnimatedProps, anduseAnimatedReaction.
Performance guidance
- Worklets are short-running functions executed on the UI thread.
- Avoid reading shared values on the JS thread during normal React rendering logic.
- Prefer animating non-layout properties (
transform,opacity, etc.) over layout-affecting ones. - Memoise frame callbacks and gesture objects.
- If you need to animate lots of simultaneously moving visuals, using Skia instead of many individual React components is explicitly recommended.
- iOS 120 fps support depends on
CADisableMinimumFrameDurationOnPhoneinInfo.plist. useReducedMotion()lets you synchronously query the system reduced-motion setting.
React Native Gesture Handler
Core setup
- Gesture Handler uses the platform native touch system and integrates closely with Reanimated.
- Wrap the app as close to the root as possible with
GestureHandlerRootView. - Gestures are not recognised outside that root, and gesture relations only work within the same root view.
- Gesture-driven interactions on the UI thread are documented primarily with Reanimated.
Source URLs
React Native Skia
- https://shopify.github.io/react-native-skia/docs/getting-started/installation/
- https://shopify.github.io/react-native-skia/docs/canvas/overview/
- https://shopify.github.io/react-native-skia/docs/canvas/rendering-modes/
- https://shopify.github.io/react-native-skia/docs/canvas/contexts/
- https://shopify.github.io/react-native-skia/docs/animations/animations/
- https://shopify.github.io/react-native-skia/docs/animations/gestures/
- https://shopify.github.io/react-native-skia/docs/animations/hooks/
- https://shopify.github.io/react-native-skia/docs/animations/textures/
- https://shopify.github.io/react-native-skia/docs/shapes/path/
- https://shopify.github.io/react-native-skia/docs/shapes/pictures/
- https://shopify.github.io/react-native-skia/docs/shapes/atlas/
- https://shopify.github.io/react-native-skia/docs/group/
- https://shopify.github.io/react-native-skia/docs/text/paragraph/
- https://shopify.github.io/react-native-skia/docs/images/
- https://shopify.github.io/react-native-skia/docs/snapshotviews/
- https://shopify.github.io/react-native-skia/docs/shaders/overview/
- https://shopify.github.io/react-native-skia/docs/shaders/gradients/
- https://shopify.github.io/react-native-skia/docs/image-filters/runtime-shader/
- https://shopify.github.io/react-native-skia/docs/getting-started/web/
- https://shopify.github.io/react-native-skia/docs/getting-started/headless/
- https://shopify.github.io/react-native-skia/docs/getting-started/bundle-size/
- https://shopify.github.io/react-native-skia/docs/video/
- https://shopify.github.io/react-native-skia/docs/skottie/
React Native Reanimated
- https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/
- https://docs.swmansion.com/react-native-reanimated/docs/guides/worklets/
- https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/
- https://docs.swmansion.com/react-native-reanimated/docs/guides/web-support/
- https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/
- https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue/
- https://docs.swmansion.com/react-native-reanimated/docs/device/useReducedMotion/
React Native Gesture Handler
- https://docs.swmansion.com/react-native-gesture-handler/docs/
- https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation/
- https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/use-pinch-gesture/
- https://docs.swmansion.com/react-native-gesture-handler/docs/2.x/gestures/pan-gesture/
Performance playbook
Use this file when the task is explicitly about performance or when a visually ambitious element risks becoming too expensive.
1. Keep work on the right thread
Preferred
- shared values for animated state
- derived values for computed geometry or props
- worklets for per-frame transforms or buffers
- Skia props driven directly from those values
Avoid
- React state updates every frame
- reading shared values on the JS thread during ordinary render logic
- routing simple Skia prop animation through
createAnimatedComponentoruseAnimatedProps
2. Prefer non-layout animation
Cheaper / safer:
transform- opacity
- radius
- path trim
- gradient positions
- shader uniforms
More expensive / awkward:
- layout-affecting view changes as the main animation vehicle
- rebuilding large React trees for visual-only motion
3. Use the right render mode
- Retained mode: best default for UI-like animated elements
- Picture: when command count changes frame-to-frame
- Atlas: when many instances share one texture
- Texture hooks: when a composed result should be cached and reused
Quick smell tests
- Hundreds of identical sparkles?
Atlas - Trail length changes every frame?
Picture - One premium card with three animated orbs? retained mode
- Procedural animated background? shader or retained shapes, depending on complexity
4. Reduce draw-cost before reducing visual ambition
Common wins:
- reuse one blurred orb texture instead of drawing many slightly different ones
- clip once at a parent group instead of repeating clipping on many children
- apply one parent transform instead of many child transforms
- pre-render recurring motifs as textures when reuse is heavy
- memoise paths, paragraphs, and gesture objects
5. Handle resources explicitly
useImage()is async; treatnullas a normal loading state- custom fonts must be loaded before
Paragraphconstruction - video frames may be
nulluntil ready - snapshots and headless work should be explicit, not accidental
6. Web-specific guidance
- Gate Skia rendering until CanvasKit is loaded.
- In Expo web, rerun
setup-skia-webafter Skia upgrades unless using a CDN. - Static canvases can use
__destroyWebGLContextAfterRender={true}to release the WebGL context after render. - If Reanimated runs on web without the Babel plugin, remember dependency arrays for
useDerivedValue,useAnimatedStyle,useAnimatedProps, anduseAnimatedReaction.
7. Native-specific guidance
androidWarmupis only for static, fully opaque drawings.- If ultra-smooth 120 fps animation matters on iOS, verify
CADisableMinimumFrameDurationOnPhone. - Reanimated 4 requires
react-native-workletsand correct Babel setup in Community CLI apps.
8. Gesture guidance
- Memoise gesture objects.
- Keep transforms in shared values.
- Use
GestureHandlerRootViewclose to the app root. - For element-specific hit regions, mirror transforms onto overlay views.
9. Anti-patterns worth flagging immediately
interpolateColorfrom Reanimated used for Skia colours instead of SkiainterpolateColorsmakeImageFromViewwithoutcollapsable={false}on the captured rootRuntimeShaderused as an image filter without considering pixel density- rich text implemented with ad hoc text nodes instead of
Paragraph - large particle systems built as many individual React Native views
- layered blurs and translucency everywhere with no focal hierarchy
10. Reduced motion and degradation strategy
For decorative motion:
- provide a static frame or slower version when reduced motion is enabled
- degrade signature effects in layers:
- remove secondary motion first
- reduce blur radius second
- switch to static gradient last
Final review checklist
Before finishing, ask:
1. Is the chosen render mode the simplest one that works? 2. Are Skia props driven directly from shared/derived values? 3. Is there any avoidable JS-thread churn? 4. Are gesture objects memoised? 5. Are fonts/images/video handled asynchronously and safely? 6. Does web bootstrapping need patching too? 7. Is reduced motion handled? 8. Can the user understand why the implementation is performant?
#!/usr/bin/env python3
"""
Audit a React Native / Expo repository for React Native Skia readiness,
common integration mistakes, and Skia-specific animation anti-patterns.
Examples:
python3 scripts/audit_skia_repo.py --root . --format markdown
python3 scripts/audit_skia_repo.py --root . --format json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
SKIA_PACKAGE = "@shopify/react-native-skia"
REANIMATED_PACKAGE = "react-native-reanimated"
WORKLETS_PACKAGE = "react-native-worklets"
RNGH_PACKAGE = "react-native-gesture-handler"
IGNORE_DIRS = {
".git",
"node_modules",
"dist",
"build",
"coverage",
".expo",
".next",
".turbo",
".gradle",
".idea",
"ios/Pods",
}
SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}
BABEL_CANDIDATES = [
"babel.config.js",
"babel.config.cjs",
"babel.config.mjs",
".babelrc",
".babelrc.js",
".babelrc.cjs",
".babelrc.json",
]
ROOT_FILE_CANDIDATES = [
"App.tsx",
"App.ts",
"App.js",
"index.tsx",
"index.ts",
"index.js",
"src/App.tsx",
"src/App.ts",
"src/App.js",
"app/_layout.tsx",
"app/_layout.ts",
"app/index.tsx",
"app/index.ts",
]
FEATURE_PATTERNS = {
"skia_import": re.compile(r"@shopify/react-native-skia"),
"canvas": re.compile(r"\bCanvas\b"),
"load_skia_web": re.compile(r"\bLoadSkiaWeb\b"),
"with_skia_web": re.compile(r"\bWithSkiaWeb\b"),
"paragraph": re.compile(r"\bParagraph\b|\bParagraphBuilder\b"),
"shader": re.compile(r"\bRuntimeEffect\b|\bShader\b|\bRuntimeShader\b"),
"picture": re.compile(r"\bPicture\b"),
"atlas": re.compile(r"\bAtlas\b"),
"texture_hooks": re.compile(r"\buseTexture\b|\buseImageAsTexture\b|\busePictureAsTexture\b"),
"use_image": re.compile(r"\buseImage\b"),
"use_video": re.compile(r"\buseVideo\b"),
"make_image_from_view": re.compile(r"\bmakeImageFromView\b"),
"skottie": re.compile(r"\bSkottie\b"),
"gesture_api": re.compile(r"\bGesture\b|\bGestureDetector\b|\bGestureHandlerRootView\b"),
"reanimated_values": re.compile(r"\buseSharedValue\b|\buseDerivedValue\b|\bwithTiming\b|\bwithSpring\b|\bwithRepeat\b"),
"create_animated_component": re.compile(r"\bcreateAnimatedComponent\b"),
"use_animated_props": re.compile(r"\buseAnimatedProps\b"),
"interpolate_color": re.compile(r"\binterpolateColor\b"),
"android_warmup": re.compile(r"\bandroidWarmup\b"),
"runtime_shader": re.compile(r"\bRuntimeShader\b"),
}
FINDING_SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Audit a React Native or Expo repo for React Native Skia compatibility and common pitfalls."
)
parser.add_argument(
"--root",
default=".",
help="Repository root to inspect. Defaults to the current directory.",
)
parser.add_argument(
"--format",
choices=("markdown", "json"),
default="markdown",
help="Output format. Defaults to markdown.",
)
parser.add_argument(
"--max-hits",
type=int,
default=6,
help="Maximum file hits to show per feature in markdown output. Defaults to 6.",
)
return parser.parse_args()
def read_text_if_exists(path: Path) -> Optional[str]:
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except UnicodeDecodeError:
return None
def load_json(path: Path) -> Optional[Dict[str, Any]]:
text = read_text_if_exists(path)
if text is None:
return None
try:
value = json.loads(text)
except json.JSONDecodeError as exc:
raise SystemExit(f"Error: could not parse JSON at {path}: {exc}") from exc
if not isinstance(value, dict):
raise SystemExit(f"Error: expected a JSON object at {path}.")
return value
def iter_source_files(root: Path) -> Iterable[Path]:
for current_root, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
current_path = Path(current_root)
for filename in files:
path = current_path / filename
if path.suffix in SOURCE_SUFFIXES:
yield path
def dependency_map(package_json: Dict[str, Any]) -> Dict[str, str]:
deps: Dict[str, str] = {}
for section in ("dependencies", "devDependencies", "peerDependencies"):
values = package_json.get(section, {})
if isinstance(values, dict):
for name, spec in values.items():
if isinstance(name, str) and isinstance(spec, str):
deps.setdefault(name, spec)
return deps
def find_package_manager(root: Path, package_json: Dict[str, Any]) -> str:
package_manager = package_json.get("packageManager")
if isinstance(package_manager, str) and package_manager:
return package_manager.split("@", 1)[0]
if (root / "bun.lockb").exists() or (root / "bun.lock").exists():
return "bun"
if (root / "pnpm-lock.yaml").exists():
return "pnpm"
if (root / "yarn.lock").exists():
return "yarn"
if (root / "package-lock.json").exists():
return "npm"
return "unknown"
def extract_version_pair(spec: Optional[str]) -> Optional[Tuple[int, int]]:
if not spec:
return None
match = re.search(r"(\d+)\.(\d+)", spec)
if not match:
return None
return int(match.group(1)), int(match.group(2))
def extract_major(spec: Optional[str]) -> Optional[int]:
pair = extract_version_pair(spec)
return None if pair is None else pair[0]
def parse_ios_target(root: Path) -> Optional[str]:
text = read_text_if_exists(root / "ios" / "Podfile")
if not text:
return None
match = re.search(r"platform\s*:ios\s*,\s*['\"](\d+(?:\.\d+)?)['\"]", text)
return match.group(1) if match else None
def parse_android_min_sdk(root: Path) -> Optional[int]:
candidates = [
root / "android" / "app" / "build.gradle",
root / "android" / "app" / "build.gradle.kts",
root / "android" / "build.gradle",
root / "android" / "build.gradle.kts",
]
patterns = [
re.compile(r"\bminSdkVersion\s*=?\s*(\d+)"),
re.compile(r"\bminSdk\s*=?\s*(\d+)"),
]
for path in candidates:
text = read_text_if_exists(path)
if not text:
continue
for pattern in patterns:
match = pattern.search(text)
if match:
try:
return int(match.group(1))
except ValueError:
pass
return None
def scan_sources(root: Path) -> Dict[str, Any]:
feature_hits = {name: [] for name in FEATURE_PATTERNS}
total = 0
file_texts: Dict[str, str] = {}
for path in iter_source_files(root):
total += 1
text = read_text_if_exists(path)
if text is None:
continue
rel = str(path.relative_to(root))
file_texts[rel] = text
for name, pattern in FEATURE_PATTERNS.items():
if pattern.search(text):
feature_hits[name].append(rel)
return {
"total_source_files_scanned": total,
"feature_hits": feature_hits,
"file_texts": file_texts,
}
def has_web_surface(root: Path, package_json: Dict[str, Any], feature_hits: Dict[str, List[str]]) -> bool:
scripts = package_json.get("scripts", {})
if isinstance(scripts, dict) and "web" in scripts:
return True
if feature_hits["load_skia_web"] or feature_hits["with_skia_web"]:
return True
for candidate in ("index.web.tsx", "index.web.ts", "index.web.jsx", "index.web.js"):
if (root / candidate).exists():
return True
return False
def has_expo(package_json: Dict[str, Any]) -> bool:
deps = dependency_map(package_json)
return "expo" in deps
def read_babel_config(root: Path) -> Optional[Tuple[str, str]]:
for candidate in BABEL_CANDIDATES:
path = root / candidate
text = read_text_if_exists(path)
if text is not None:
return candidate, text
return None
def parse_babel_plugins(text: str) -> Optional[List[str]]:
match = re.search(r"plugins\s*:\s*\[(.*?)\]", text, re.DOTALL)
if not match:
return None
body = match.group(1)
return re.findall(r"['\"]([^'\"]+)['\"]", body)
def has_gesture_root(root: Path, file_texts: Dict[str, str]) -> bool:
for rel in ROOT_FILE_CANDIDATES:
text = file_texts.get(rel)
if text and "GestureHandlerRootView" in text:
return True
return any("GestureHandlerRootView" in text for text in file_texts.values())
def trusted_dependency_enabled(package_json: Dict[str, Any]) -> Optional[bool]:
trusted = package_json.get("trustedDependencies")
if trusted is None:
return None
if isinstance(trusted, list):
return SKIA_PACKAGE in trusted
return None
def yarn_scripts_enabled(root: Path) -> Optional[bool]:
text = read_text_if_exists(root / ".yarnrc.yml")
if text is None:
return None
match = re.search(r"^\s*enableScripts\s*:\s*(\S+)\s*$", text, re.MULTILINE)
if not match:
return None
value = match.group(1).strip().lower().strip("'\"")
if value == "false":
return False
if value == "true":
return True
return None
def add_finding(findings: List[Dict[str, Any]], severity: str, code: str, message: str, files: Optional[Sequence[str]] = None) -> None:
entry: Dict[str, Any] = {
"severity": severity,
"code": code,
"message": message,
}
if files:
entry["files"] = list(files)
findings.append(entry)
def analyse_antipatterns(findings: List[Dict[str, Any]], feature_hits: Dict[str, List[str]], file_texts: Dict[str, str]) -> None:
skia_files = set(feature_hits["skia_import"])
for rel in sorted(skia_files):
text = file_texts[rel]
if FEATURE_PATTERNS["create_animated_component"].search(text) or FEATURE_PATTERNS["use_animated_props"].search(text):
add_finding(
findings,
"warning",
"SKIA_WRAPPED_AS_ANIMATED_COMPONENT",
"This file uses createAnimatedComponent/useAnimatedProps alongside Skia. Skia supports passing shared/derived values directly to Skia props.",
[rel],
)
if "interpolateColor" in text and "@shopify/react-native-skia" in text:
add_finding(
findings,
"warning",
"SKIA_COLOR_INTERPOLATION",
"This file uses Reanimated interpolateColor alongside Skia. Prefer Skia interpolateColors for Skia colour props.",
[rel],
)
if "makeImageFromView" in text and "collapsable={false}" not in text.replace(" ", ""):
add_finding(
findings,
"warning",
"SNAPSHOT_COLLAPSABLE_MISSING",
"This file uses makeImageFromView but does not obviously set collapsable={false} on the captured root view.",
[rel],
)
if "RuntimeShader" in text and "PixelRatio" not in text:
add_finding(
findings,
"info",
"RUNTIME_SHADER_NO_PIXEL_RATIO",
"This file uses RuntimeShader without an obvious PixelRatio-based supersampling path. Review output crispness on high-density screens.",
[rel],
)
if "Gesture." in text and "useMemo(" not in text:
add_finding(
findings,
"info",
"GESTURE_NOT_MEMOISED",
"This file defines gesture objects without an obvious useMemo wrapper. Memoising gestures reduces reattachment work.",
[rel],
)
if "androidWarmup" in text and any(token in text for token in ("useSharedValue", "withTiming", "withRepeat", "opacity", "transparent")):
add_finding(
findings,
"warning",
"ANDROID_WARMUP_DYNAMIC_SCENE",
"This file enables androidWarmup in a scene that also looks animated or translucent. androidWarmup is intended for static, fully opaque canvases.",
[rel],
)
def analyse_repo(root: Path) -> Dict[str, Any]:
package_json_path = root / "package.json"
package_json = load_json(package_json_path)
if package_json is None:
return {
"root": str(root.resolve()),
"environment": {},
"usage": {},
"findings": [
{
"severity": "error",
"code": "NO_PACKAGE_JSON",
"message": f"No package.json found at {package_json_path}. Point --root at a React Native or Expo repository.",
}
],
"recommendations": [
"Run the audit against the repository root that contains package.json."
],
}
deps = dependency_map(package_json)
pm = find_package_manager(root, package_json)
source_scan = scan_sources(root)
feature_hits = source_scan["feature_hits"]
file_texts = source_scan["file_texts"]
ios_target = parse_ios_target(root)
android_min_sdk = parse_android_min_sdk(root)
expo = has_expo(package_json)
web_surface = has_web_surface(root, package_json, feature_hits)
findings: List[Dict[str, Any]] = []
recommendations: List[str] = []
skia_spec = deps.get(SKIA_PACKAGE)
rn_spec = deps.get("react-native")
react_spec = deps.get("react")
reanimated_spec = deps.get(REANIMATED_PACKAGE)
worklets_spec = deps.get(WORKLETS_PACKAGE)
rngh_spec = deps.get(RNGH_PACKAGE)
skia_pair = extract_version_pair(skia_spec)
rn_pair = extract_version_pair(rn_spec)
react_major = extract_major(react_spec)
reanimated_major = extract_major(reanimated_spec)
if skia_spec is None:
add_finding(
findings,
"warning",
"SKIA_NOT_INSTALLED",
f"{SKIA_PACKAGE} is not declared in package.json.",
)
recommendations.append(f"Install {SKIA_PACKAGE} before applying Skia-specific patches.")
if rn_pair and react_major is not None and skia_pair:
if (rn_pair < (0, 79) or react_major < 19) and (skia_pair[0] > 1 or (skia_pair[0] == 1 and skia_pair[1] > 12)):
add_finding(
findings,
"error",
"VERSION_COMPATIBILITY",
"This app appears to be on an older React / React Native line but a newer React Native Skia version. Current docs say older apps should stay on @shopify/react-native-skia <= 1.12.4.",
)
recommendations.append("Pin @shopify/react-native-skia to <= 1.12.4 or upgrade React/React Native to the current supported line.")
elif rn_pair >= (0, 79) and react_major >= 19 and skia_pair[0] == 1 and skia_pair[1] <= 12:
add_finding(
findings,
"info",
"OLD_SKIA_ON_NEW_STACK",
"The app looks new enough for the current React Native Skia line but is pinned to an older Skia version.",
)
if ios_target:
try:
if float(ios_target) < 14.0:
add_finding(
findings,
"error",
"IOS_TARGET_TOO_LOW",
f"Podfile target is iOS {ios_target}. Current React Native Skia docs require iOS 14+.",
)
except ValueError:
pass
if android_min_sdk is not None:
if android_min_sdk < 21:
add_finding(
findings,
"error",
"ANDROID_MIN_SDK_TOO_LOW",
f"Android minSdk is {android_min_sdk}. Current React Native Skia docs require API 21+.",
)
elif feature_hits["use_video"] and android_min_sdk < 26:
add_finding(
findings,
"error",
"ANDROID_VIDEO_MIN_SDK_TOO_LOW",
f"Video support is used but Android minSdk is {android_min_sdk}. React Native Skia video requires API 26+.",
)
if pm == "bun":
trusted = trusted_dependency_enabled(package_json)
if trusted is False:
add_finding(
findings,
"warning",
"BUN_TRUSTED_DEPENDENCIES",
"Bun project does not trust @shopify/react-native-skia in trustedDependencies. The Skia postinstall step can be blocked.",
)
recommendations.append("Add @shopify/react-native-skia to trustedDependencies and reinstall.")
if pm == "yarn":
scripts_enabled = yarn_scripts_enabled(root)
if scripts_enabled is False:
add_finding(
findings,
"warning",
"YARN_SCRIPTS_DISABLED",
"Yarn Berry has enableScripts=false. React Native Skia's postinstall step will be skipped.",
)
recommendations.append("Enable scripts in .yarnrc.yml and reinstall dependencies.")
if feature_hits["reanimated_values"] and reanimated_spec is None:
add_finding(
findings,
"error",
"REANIMATED_MISSING",
"Source files use Reanimated APIs but react-native-reanimated is not declared in package.json.",
)
if reanimated_major is not None and reanimated_major >= 4 and worklets_spec is None:
add_finding(
findings,
"error",
"WORKLETS_MISSING",
"Reanimated 4 is installed but react-native-worklets is not declared. Current docs require it.",
)
recommendations.append("Install react-native-worklets and rebuild native apps.")
babel = read_babel_config(root)
if babel:
babel_path, babel_text = babel
plugins = parse_babel_plugins(babel_text)
if reanimated_major is not None and reanimated_major >= 4:
if "react-native-worklets/plugin" not in babel_text and not expo:
add_finding(
findings,
"warning",
"WORKLETS_PLUGIN_MISSING",
f"{babel_path} does not obviously include react-native-worklets/plugin. Community CLI apps need it, listed last.",
)
elif plugins is not None and plugins and plugins[-1] != "react-native-worklets/plugin":
add_finding(
findings,
"warning",
"WORKLETS_PLUGIN_NOT_LAST",
f"{babel_path} includes react-native-worklets/plugin but it does not appear to be the last Babel plugin.",
)
if web_surface and reanimated_major is not None and reanimated_major >= 4 and "@babel/plugin-proposal-export-namespace-from" not in babel_text:
add_finding(
findings,
"info",
"WEB_EXPORT_NAMESPACE_PLUGIN_MISSING",
f"{babel_path} does not obviously include @babel/plugin-proposal-export-namespace-from. Reanimated web docs recommend it for react-native-web builds.",
)
if feature_hits["gesture_api"] and rngh_spec is None:
add_finding(
findings,
"error",
"GESTURE_HANDLER_MISSING",
"Source files use Gesture Handler APIs but react-native-gesture-handler is not declared in package.json.",
)
if rngh_spec is not None and not has_gesture_root(root, file_texts):
add_finding(
findings,
"warning",
"GESTURE_ROOT_MISSING",
"GestureHandlerRootView was not found in likely root files. Gestures must live under a GestureHandlerRootView near the app root.",
)
if web_surface and skia_spec is not None and not (feature_hits["load_skia_web"] or feature_hits["with_skia_web"]):
add_finding(
findings,
"warning",
"SKIA_WEB_BOOTSTRAP_MISSING",
"This looks like a web-capable app using Skia, but no LoadSkiaWeb / WithSkiaWeb usage was found.",
)
recommendations.append("Gate web rendering until CanvasKit loads via LoadSkiaWeb() or WithSkiaWeb.")
scripts = package_json.get("scripts", {})
if expo and web_surface and skia_spec is not None:
setup_script_present = False
if isinstance(scripts, dict):
for value in scripts.values():
if isinstance(value, str) and "setup-skia-web" in value:
setup_script_present = True
break
if not setup_script_present:
add_finding(
findings,
"info",
"SETUP_SKIA_WEB_NOT_FOUND",
"Expo web surface detected but no script mentioning setup-skia-web was found. Remember to rerun setup-skia-web after Skia upgrades unless CanvasKit comes from a CDN.",
)
analyse_antipatterns(findings, feature_hits, file_texts)
if feature_hits["paragraph"]:
recommendations.append("Use Paragraph for wrapped or multi-style text and keep font loading explicit.")
if feature_hits["make_image_from_view"]:
recommendations.append("Check every captured root view for collapsable={false}.")
if feature_hits["runtime_shader"]:
recommendations.append("Review RuntimeShader outputs on high-density screens and supersample if needed.")
if feature_hits["atlas"]:
recommendations.append("Verify Atlas is used only when many instances truly share one texture.")
if feature_hits["picture"]:
recommendations.append("Verify Picture is justified by a dynamic command list rather than simple retained-mode animation.")
recommendations.extend([
"Prefer shared/derived values directly on Skia props.",
"Prefer transform/opacity style changes over layout-affecting animation.",
"Memoise gestures and heavy derived geometry when possible.",
])
deduped_recommendations: List[str] = []
seen: set[str] = set()
for item in recommendations:
if item not in seen:
seen.add(item)
deduped_recommendations.append(item)
environment = {
"package_manager": pm,
"expo": expo,
"web_surface": web_surface,
"versions": {
"react-native": rn_spec,
"react": react_spec,
SKIA_PACKAGE: skia_spec,
REANIMATED_PACKAGE: reanimated_spec,
WORKLETS_PACKAGE: worklets_spec,
RNGH_PACKAGE: rngh_spec,
},
"platform_targets": {
"ios": ios_target,
"android_min_sdk": android_min_sdk,
},
"babel_config": babel[0] if babel else None,
}
usage = {
"total_source_files_scanned": source_scan["total_source_files_scanned"],
"feature_hits": feature_hits,
}
findings.sort(key=lambda item: (FINDING_SEVERITY_ORDER[item["severity"]], item["code"]))
return {
"root": str(root.resolve()),
"environment": environment,
"usage": usage,
"findings": findings,
"recommendations": deduped_recommendations,
}
def format_hits(files: Sequence[str], max_hits: int) -> str:
if not files:
return "none"
visible = list(files[:max_hits])
suffix = ""
if len(files) > max_hits:
suffix = f", +{len(files) - max_hits} more"
return ", ".join(visible) + suffix
def render_markdown(report: Dict[str, Any], max_hits: int) -> str:
lines: List[str] = []
lines.append("# React Native Skia repo audit")
lines.append("")
lines.append(f"- **Root:** `{report['root']}`")
environment = report.get("environment", {})
usage = report.get("usage", {})
findings = report.get("findings", [])
recommendations = report.get("recommendations", [])
if environment:
versions = environment.get("versions", {})
lines.append("")
lines.append("## Environment")
lines.append("")
lines.append(f"- **Package manager:** `{environment.get('package_manager')}`")
lines.append(f"- **Expo:** `{environment.get('expo')}`")
lines.append(f"- **Web surface detected:** `{environment.get('web_surface')}`")
if environment.get("babel_config"):
lines.append(f"- **Babel config:** `{environment['babel_config']}`")
ios_target = environment.get("platform_targets", {}).get("ios")
android_min_sdk = environment.get("platform_targets", {}).get("android_min_sdk")
if ios_target:
lines.append(f"- **iOS target:** `{ios_target}`")
if android_min_sdk is not None:
lines.append(f"- **Android minSdk:** `{android_min_sdk}`")
lines.append("- **Declared versions:**")
for key, value in versions.items():
if value:
lines.append(f" - `{key}`: `{value}`")
if usage:
lines.append("")
lines.append("## Usage signals")
lines.append("")
lines.append(f"- **Source files scanned:** `{usage.get('total_source_files_scanned', 0)}`")
feature_hits = usage.get("feature_hits", {})
important = [
"canvas",
"paragraph",
"shader",
"picture",
"atlas",
"texture_hooks",
"use_video",
"make_image_from_view",
"gesture_api",
"load_skia_web",
"with_skia_web",
]
for name in important:
hits = feature_hits.get(name, [])
if hits:
lines.append(f"- **{name}:** {format_hits(hits, max_hits)}")
lines.append("")
lines.append("## Findings")
lines.append("")
if not findings:
lines.append("- No obvious issues found.")
else:
for finding in findings:
prefix = finding["severity"].upper()
code = finding["code"]
message = finding["message"]
extra = ""
if finding.get("files"):
extra = f" Files: {', '.join(finding['files'])}"
lines.append(f"- **{prefix} [{code}]** {message}{extra}")
if recommendations:
lines.append("")
lines.append("## Recommended next actions")
lines.append("")
for item in recommendations:
lines.append(f"- {item}")
return "\n".join(lines) + "\n"
def main() -> int:
args = parse_args()
root = Path(args.root).resolve()
report = analyse_repo(root)
if args.format == "json":
json.dump(report, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
sys.stdout.write(render_markdown(report, args.max_hits))
return 0
if __name__ == "__main__":
raise SystemExit(main())