npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-native-nitro-theme-transition

v1.0.0

Published

GPU-driven theme-change transitions for React Native — sixteen effects including circular reveal, iris, wipe, split, blinds, blur, Liquid Glass, pixelize, dissolve, ripple and shatter, without Skia, Reanimated or any JS animation library.

Readme

react-native-nitro-theme-transition

Native, GPU-driven theme-change transitions for React Native — sixteen of them: circular reveal and its inverse, iris, wipe, split, barn door, blinds, fade, zoom, blur, Liquid Glass, pixelize, dissolve, stripes, ripple and shatter.

No Skia. No Reanimated. No JS animation library. The animation is Core Animation on iOS and the platform animators on Android, both interpolated by the OS render thread, so a busy JavaScript thread cannot drop a frame of it.

Built on Nitro Modules. iOS and Android.

▶ Watch all sixteen, full resolution →

npm install react-native-nitro-theme-transition react-native-nitro-modules

Then rebuild — this ships native code, so an existing dev client will not have it:

npx expo prebuild
npx expo run:ios      # or: run:android

Until you rebuild, withThemeTransition degrades to an instant theme change rather than crashing.

Usage

import { withThemeTransition } from 'react-native-nitro-theme-transition';

withThemeTransition(
  () => setScheme(s => (s === 'light' ? 'dark' : 'light')), // must be synchronous
  { kind: 'circularReveal', durationMs: 650, origin: { x, y } },
);

That is the whole API surface. Pass the touch point as origin and the reveal starts from the user's finger:

<Pressable
  onPress={event => {
    const { pageX, pageY } = event.nativeEvent;
    withThemeTransition(toggleTheme, { origin: { x: pageX, y: pageY } });
  }}
/>

It is not a theming library

It has no idea what a theme is. It snapshots the screen, runs your callback, and animates the snapshot away. Anything that changes the UI synchronously works — a theme, a locale/RTL flip, a font-size change, a density switch.

There is no dependency on Unistyles or any styling library. The example/ app drives four palettes from a plain module-level store and React Native's own StyleSheet to prove it — no styling runtime, no animation library.

applyTheme runs exactly once in every path: if the native module is missing, the capture fails, or the platform is web, the change still happens — just without animation. The animation is never load-bearing.

Demos

In a real app

Brand, mode, font and corner-radius changes in a shipping Unistyles app — the transition riding on top of a genuine whole-app restyle, not a toy screen.

JS thread under load

Watch the JS counter in the perf overlay collapse while the reveal keeps running. The animation is submitted to the OS render thread once, so JavaScript is not in the loop and cannot stutter it.

Options

| Option | Default | Notes | | -------------- | ------------------ | ---------------------------------------------------------- | | kind | 'circularReveal' | see below | | durationMs | 650 | below ~500ms most reveals read as a flicker; try longer for pixlated | | origin | screen centre | where the effect starts, in dp — pass the touch's pageX/pageY; used by the shape reveals and ripple | | direction | 'bottom' | slide / split / barnDoor / blinds / stripes / swept blur — the edge, or the axis for the two-edged kinds | | angleDeg | 0 | slide / split / barnDoor / blinds / swept blur — tilt of the boundary line, in degrees | | shape | 'hexagon' | iris'circle' \| 'diamond' \| 'hexagon' \| 'roundedRect' | | blurStyle | 'uniform' | blur'uniform' \| 'sweep' | | bands | 6 | blinds — louvre count, clamped to 2…24 | | settleFrames | 2 | frames to hold before revealing, so the change has painted |

THEME_TRANSITION_KINDS and THEME_TRANSITION_DIRECTIONS are exported as arrays for building pickers. isThemeTransitionAvailable() reports whether the native module is present.

origin — just pass pageX/pageY

React Native reports a touch relative to the surface it happened in, and a presented screen — a modal, a form sheet — is its own surface. A sheet resting at its half detent starts ~450dp down the window, so a press inside it arrives with a pageY that is short by exactly that much.

You do not have to correct for it, and in JavaScript you cannot: inside a sheet even measureInWindow reports positions in that sheet's surface. The library translates the point natively, reading the presentation's own frame at capture time — so dragging a sheet between detents needs no handling at all.

const { pageX, pageY } = event.nativeEvent;
withThemeTransition(applyTheme, { origin: { x: pageX, y: pageY } });

settleFrames — the one thing to check

This is the only setting whose right value depends on your theme system.

The snapshot is held for settleFrames frames before the reveal starts, so the new state has painted underneath it. The default of 2 suits a theme system that applies synchronously — Unistyles (a C++ shadow-tree commit), direct native writes, and so on.

If you drive the theme with React state, your callback returns having only scheduled a re-render: React still has to reconcile, commit, mount and paint. On a large tree that can exceed two frames, and revealing early means briefly animating down to the old colours. Raise it:

withThemeTransition(applyTheme, { settleFrames: 4 });

pixlated also waits two animation frames before commit and then polls the second (new-theme) snapshot until it differs from the first — settleFrames alone is not enough for that kind when the theme is React-driven.

Kinds

All sixteen, recorded on Android and iOS side by side. Full-resolution video for every one is on the demo site.

| kind | What the outgoing screen does | | ------------------------- | ------------------------------------------------------------------ | | 'circularReveal' | collapses into a circle at origin | | 'circularRevealInverse' | a hole opens out from origin and grows | | 'iris' | collapses into a shape — diamond, hexagon or squircle | | 'slide' | a straight edge wipes across, painting the new theme | | 'split' | two edges part from the centre, retreating to opposite sides | | 'barnDoor' | two edges close in; the old screen shrinks to a middle band | | 'blinds' | bands parallel louvres, each wiping across itself at once | | 'fade' | dissolves | | 'zoom' | scales up and fades | | 'blur' | blurs away — all at once, or swept across; see blurStyle | | 'liquidGlass' | a sheet of Liquid Glass slides down, the theme swaps behind it, and it slides back up — iOS 26+, falls back to blur elsewhere | | 'pixlated' | breaks into pixels; the colour swap rides the mosaic mid-transition | | 'dissolve' | disintegrates into grain, speck by speck | | 'stripes' | a grainy edge sweeps across, ordered along direction | | 'ripple' | wavefronts expand from origin | | 'shatter' | breaks into shards that fall away in turn |

They come from four families, and the family is what actually matters:

| Family | Kinds | The one idea | | --- | --- | --- | | Shape mask | circularReveal, circularRevealInverse, iris | a shrinking outline at a point | | Straight boundaries | slide, split, barnDoor, blinds | half-planes sweeping along a normal | | Mask ladder | dissolve, stripes, ripple, shatter | a per-cell disappearing order, pre-built | | Whole-layer | fade, zoom, blur, liquidGlass, pixlated | animate the copy itself |

All sixteen share one timing curve, so the two platforms agree. Each also has a minimum duration — 200ms for fade, 240ms for zoom, 260ms for the reveals and the sweeps, 300ms for blinds and blur, 420ms for dissolve and stripes, 480ms for ripple and shatter, 520ms for pixlated, 620ms for liquidGlass. Below those a full-screen copy coming apart has too few frames to read as motion. Pass durationMs: 0 to opt out of animating entirely; that is never clamped.

circularReveal and its inverse are the same shape run opposite ways, and they are each other's natural counterpart: whichever is used for light→dark, the other reads as "undo" for dark→light.

slide is a wipe, not a translation — nothing on screen moves. The old pixels stay exactly where they are and stop being drawn as the boundary passes over them, so it reads as a line painting the new colours across the screen. Translating the snapshot instead drags the whole UI sideways, which looks like a page transition rather than a theme change.

blurStyle — all at once, or swept

'uniform' is the original behaviour: the whole screen blurs, recedes slightly and fades. 'sweep' is a wipe that brings the NEW theme in out of focus and pulls it sharp as it arrives:

withThemeTransition(applyTheme, {
  kind: 'blur',
  blurStyle: 'sweep',
  direction: 'bottom',
});

The blur is on the incoming side, not the outgoing one. The old screen is never blurred: it stays crisp right up to the edge and is simply taken away by the mask, while the theme arriving behind it starts heavily out of focus and sharpens as the wipe crosses. Blurring the outgoing copy instead reads as the screen you are leaving being smeared off, which is the opposite of the intent.

The swept variant takes direction and angleDeg exactly as slide does.

angleDeg — tilting the wipe

slide and split sweep a boundary along a normal rather than clipping a rectangle, so the line can be raked over without changing where it travels:

withThemeTransition(applyTheme, { kind: 'slide', direction: 'top', angleDeg: 40 });

That still leaves through the top; the edge doing the leaving is 40° off level. Positive is clockwise on screen, and the sweep always ends fully uncovered whatever the angle, because it measures its own travel along the tilted normal.

split uses both edges at once, so direction picks the axis for it: 'top'/'bottom' part the screen horizontally, 'left'/'right' vertically.

The mask ladder — dissolve, stripes, ripple, shatter

The reference for this effect is a Skia fragment shader that discards a pixel when its cell's noise falls below a rising threshold. There is no shader here, and there does not need to be one: the noise is static, so a cell simply has a fixed time at which it disappears, and every frame the effect can ever draw is known in advance.

So it is a pre-built stack of alpha masks, computed once and shared by every transition that matches. On iOS the whole sequence is handed to the render server as discrete keyframes, which puts it in the same class as the reveals — nothing runs per frame at all.

Nothing in that argument depends on the value being noise, which is why four kinds share the implementation. Any per-cell number in 0…1 is a valid disappearing order: dissolve uses the hash, stripes uses position along direction roughened with a little hash, ripple uses distance from origin with a sine riding on it, and shatter uses the hash of the nearest of 48 seeds so whole Voronoi shards leave together.

pixlated — dual snapshot

Unlike the other kinds, pixelize needs both themes on screen at once: the outgoing frame and a second capture of the already-painted incoming theme. They share one triangle blockSize curve (chunkiest at the midpoint); the old mosaic fades out over the full duration so the colour change is obvious mid-transition, behind the pixels — the same look as Skia's pixelize, without Skia.

For React-driven themes the library also yields two animation frames before commit and, on the native side, polls the second capture until it differs from the first (or hits a short frame budget). Capturing too early samples the old colours twice, which makes the mid crossfade a no-op and the real swap appear only when the overlay is removed.

How each one is done

| kind | iOS | Android | | ------------------------- | ---------------------------------------- | ----------------------------------------- | | 'fade' | UIViewPropertyAnimator, opacity | ViewPropertyAnimatoralpha | | 'circularReveal' | CAShapeLayer mask + CABasicAnimation | ViewAnimationUtils.createCircularReveal | | 'circularRevealInverse' | the same mask, even-odd fill rule | Canvas.clipOutPath in onDraw | | 'iris' | the same mask, a fixed-vertex polygon | Canvas.clipPath on the same polygon | | 'slide' | animated mask half-plane | Canvas.clipRect, or clipPath if tilted | | 'split' 'barnDoor' 'blinds' | animated mask half-planes / slabs | Canvas.clipPath over all of them | | 'blur' | UIVisualEffectView + property animator | RenderEffect.createBlurEffect (API 31+) | | 'zoom' | UIViewPropertyAnimator, scale + alpha | ViewPropertyAnimator, scale + alpha | | 'liquidGlass' | a sliding UIGlassEffect sheet, swap held behind it | falls back to blur | | 'pixlated' | dual mosaic + alpha crossfade | dual mosaic + alpha crossfade | | 'dissolve' 'stripes' 'ripple' 'shatter' | discrete CAKeyframeAnimation on a mask | ALPHA_8 mask composited DST_IN |

Below API 31 there is no RenderEffect, and the fallback is the same motion without the blur rather than a CPU blur that would drop frames.

How it works

A theme change is typically a whole-app style re-evaluation plus a tree commit, synchronous on the JS thread. It cannot be animated frame by frame. So this package does not animate the theme at all. It:

  1. takes a GPU-side copy of the screen and pins it on top;
  2. lets you change the theme underneath it, invisibly;
  3. animates the copy away with the platform's own animator.

| | The copy | Animated by | | ----------- | --------------------------------------------------------------------------------- | -------------------------------------- | | iOS | UIView.snapshotView(afterScreenUpdates:false) | Core Animation, on the render server | | Android | a RenderNode recording rasterized by HardwareRenderer into a hardware Bitmap | ValueAnimator / ViewAnimationUtils |

No pixels are ever read back to the CPU, encoded or decoded.

The Android capture must be rasterized

Worth knowing if you read the source, because it is a trap that compiles, runs, and looks like a broken animation rather than a broken capture.

Recording the view into a RenderNode and drawing that does not work. View.draw(Canvas)dispatchDrawdrawChild emits each child on a hardware canvas as drawRenderNode(child.renderNode) — a reference to the child's live RenderNode, not a copy of its pixels. The "snapshot" is therefore a live mirror: the instant the theme changes, it changes too. Every mask, clip and opacity animation then plays over content identical to what is underneath, so nothing appears to happen — except blur, where the effect itself is visible even though it is blurring the wrong frame.

So the recording is only step one. Step two renders it into an offscreen surface with HardwareRenderer, which resolves every referenced RenderNode into actual pixels. Below API 29 there is no public HardwareRenderer, so it falls back to a software Canvas — which rasterizes for the same reason.

Concurrent transitions

Switching again before a reveal finishes does not cancel anything. Each change gets its own snapshot and its own animation, and they play at the same time:

iOS: overlay window                  Android: android.R.id.content
  ├─ snapshot A  ← oldest, TOP-most      ├─ [2] snapshot A  ← oldest, TOP-most
  └─ snapshot B  ← next                  ├─ [1] snapshot B  ← next
     app window                          └─ [0] React Native root
       ↑ live app, newest theme               ↑ live app, newest theme

On iOS the copies are hosted in a window of their own, one level above the app. That is what lets the capture be a snapshot of the whole window — the only way to be sure a presented modal is in it — without copying the copies.

Every snapshot reveals whatever sits directly beneath it, which is exactly the state the app was in one step later — so newer snapshots go below older ones. The stack stays visually consistent however fast you tap, and no animation is ever cut short or restarted.

At most six run at once. Each is a full-screen layer the GPU composites every frame — and on Android a full-screen buffer, roughly 10 MB at 1080×2400 — so the stack is bounded; past the limit the oldest is dropped, which is the least visible choice because by then it is furthest through its animation. Every snapshot is released as soon as its animation ends, including an explicit recycle() so the GPU buffer is not left to the garbage collector.

Not handled

  • Reduce Motion. Deliberately the app's call, not the library's — check AccessibilityInfo.isReduceMotionEnabled() and skip the animation yourself.
  • Modal on Android. Best-effort via dialog-host capture; confirm on your RN version. On iOS the capture is a snapshot of the scene's windows, so everything presented (RN Modal, a router modal, a form sheet, an alert) is included along with its dimming.
  • System windows. Alerts, share sheets, autofill and the keyboard are drawn by the OS above the app's window and are never part of the copy.
  • Web. No native side; the callback runs and the change is instant.

Example

An Expo Router app with a drawer, native bottom tabs, modals, a form sheet and sixteen case screens — one per situation a real app runs into: reveal origins, rapid switching, a blocked JS thread, settle-frame tuning, RN Modal vs router modals, keyboards, long lists, native SwiftUI/Compose surfaces, Reduce Motion, missing-module fallbacks and rotation.

cd example
bun install          # or npm install
bun run prebuild
bun run ios          # or: bun run android

Its map is in example/README.md; the integration patterns worth copying live in example/src/theme/transition.ts.

Contributing

src/specs/ThemeTransition.nitro.ts is the source of truth. After changing it:

bun run codegen      # nitrogen
bun run build        # bob

nitrogen/generated/ is committed on purpose — CocoaPods needs it at pod install time and Gradle needs it at sync time.

License

MIT © Saleh Almashni