Skip to content

Liquid Glass Welcome Screen

A React Native guide for coding agents. Also covers liquid glass screens, glass sphere welcome, skia backdrop filter, sticker plume, swipe-up onboarding, appllama.

A welcome screen built from one gesture. A glass dome sits on the bottom edge. A swipe up carries it to the middle of the screen, and it shrinks into a "+" button as it climbs. When it lands, forty stickers surface through its glass and settle into a plume above it, and the copy and the call to action come in. A swipe down grows it again and takes the plume away.

The reference implementation is Appllama's open-source study Appllama/liquid-glass-screens, two cookbooks (Sky by day, Astro by night) that share one TypeScript component, one React Native Skia canvas, and UI-thread motion from React Native Reanimated and React Native Gesture Handler. Read it as a study of how a physical, gesture-driven screen is built. Do not ship it unchanged; see the last section before any release.

The effect is not Apple's Liquid Glass and does not use expo-glass-effect or UIKit materials. It is a sphere of refracting glass drawn with Skia runtime shaders, so it renders the same on iOS and Android, and it can bend anything drawn into the canvas, including the stickers. That also means it needs a development build or a native build: Skia runtime shaders and video decoding do not run in Expo Go.

Decide Whether the Screen Fits

Use this pattern when the first screen must carry the product's character and one gesture is the whole interaction. It suits a welcome or create screen with a single primary action. It does not suit a form, a list, or a screen that must also scroll, because the pan gesture owns the whole page.

Before you build it, read animation-and-gestures for the rules that apply to any gesture-driven surface, and react-native-ripple-refraction for the Skia runtime shader basics this guide builds on.

Draw Five Layers in One Canvas

Each cookbook is one Skia Canvas, drawn bottom to top. The lens must sit above the scene and the stickers, because a BackdropFilter only bends what is already drawn under it.

LayerWhat it is
SceneA looping cloud video (Sky), or a star layer plus an additive glow layer (Astro), drawn inside the canvas so the lens can bend it
StickersForty die-cut sprites moved by a UI-thread frame callback
LensA BackdropFilter running a runtime shader: magnification, a bevelled rim, per-channel dispersion, and a slosh offset
GlassA second runtime shader drawn over the lens: body, rim light, sheen, halo, and the motion caustic, with a night uniform that blends day and night tunings
CopyReact Native Text above the canvas: the wordmark, the hint, the headline, the rotating third line, and the pill

Keep text and buttons as ordinary React Native views layered above the Canvas. They stay crisp, they keep their accessibility roles, and the shader never has to rasterise type.

Drive the Sphere from Position

Every value on the page is a function of one shared value, p, the sphere's travel from the gate (0) to the button (1). The finger moves p one to one. The radius is a straight line between the dome and the button, so shrinking on the way up and growing on the way down read the same. Nothing has a beat of its own.

The study authors its geometry at 402 by 874 points and scales width and height separately, so the dome stays centred on the bottom edge on every phone.

QuantityValue
Dome at the gateradius 245 sx, centred on the bottom edge
Button when openradius 44 sx, centre at 0.469 height
Floor when thrown past the buttonradius 32 sx, approached with an exponential
Rubber bandpast either end the finger's travel counts at 25 percent
Sidewaysfollows the finger on a soft leash: 150 sx · tanh(dx / 150 sx)
Releaseopens when p + 0.18 · v passes 0.5, with v the release velocity in travel units, else closes
Landing spring{ damping: 15, stiffness: 120, mass: 1.05 } with the release velocity handed in
Landeddecided from position with the finger lifted: p > 0.97 opens, p < 0.80 closes

Decide "landed" from where the sphere is, not from the spring's completion callback. A hold at the top then does not flash the copy in, and an interrupted spring never leaves the page in a half state.

import { useDerivedValue, useSharedValue } from "react-native-reanimated";
import { useWindowDimensions } from "react-native";

export function useSphereGeometry() {
  const { width, height } = useWindowDimensions();
  const sx = width / 402;
  const sy = height / 874;

  const gateRadius = 245 * sx;
  const buttonRadius = 44 * sx;
  const floorRadius = 32 * sx;
  const gateCenterY = height;
  const buttonCenterY = 0.469 * height;
  const travel = gateCenterY - buttonCenterY;

  const progress = useSharedValue(0);

  const centerY = useDerivedValue(
    () => gateCenterY + (buttonCenterY - gateCenterY) * Math.min(progress.value, 1),
  );

  const radius = useDerivedValue(() => {
    const t = progress.value;
    if (t <= 1) return gateRadius + (buttonRadius - gateRadius) * t;
    const overshoot = (t - 1) * travel;
    return floorRadius + (buttonRadius - floorRadius) * Math.exp(-overshoot / (40 * sy));
  });

  return { progress, centerY, radius, travel, buttonRadius, gateRadius };
}

The pan gesture writes progress directly. Keep the rubber band and the release rule as small named functions, so the spec's two numbers (25 percent past either end, a 0.18 velocity nudge) live in one place.

import { usePanGesture } from "react-native-gesture-handler";
import { useSharedValue, withSpring, type SharedValue } from "react-native-reanimated";

const LANDING_SPRING = { damping: 15, stiffness: 120, mass: 1.05 };
const OVERDRAG = 0.25;
const VELOCITY_NUDGE = 0.18;

function rubberBand(value: number): number {
  "worklet";
  if (value < 0) return value * OVERDRAG;
  if (value > 1) return 1 + (value - 1) * OVERDRAG;
  return value;
}

function releaseTarget(position: number, velocity: number): 0 | 1 {
  "worklet";
  return position + velocity * VELOCITY_NUDGE > 0.5 ? 1 : 0;
}

export function useSphereGesture(progress: SharedValue<number>, travel: number) {
  const grabbedAt = useSharedValue(0);

  return usePanGesture({
    activeOffsetY: [-8, 8],
    onStart: () => {
      grabbedAt.set(progress.get());
    },
    onUpdate: (event) => {
      progress.set(rubberBand(grabbedAt.get() - event.translationY / travel));
    },
    onEnd: (event) => {
      const velocity = -event.velocityY / travel;
      const target = releaseTarget(progress.get(), velocity);
      progress.set(withSpring(target, { ...LANDING_SPRING, velocity }));
    },
  });
}

A gesture object does nothing until a GestureDetector mounts it. Wrap the whole page, because the sphere must answer a swipe that starts anywhere, and keep the Canvas at pointerEvents="none" so it never swallows the touch.

import { StyleSheet, View } from "react-native";
import { Canvas } from "@shopify/react-native-skia";
import { GestureDetector, usePanGesture } from "react-native-gesture-handler";
import type { ReactNode } from "react";

type SphereGesture = ReturnType<typeof usePanGesture>;
type PageProps = { gesture: SphereGesture; children: ReactNode };

export function GlassPage({ gesture, children }: PageProps) {
  return (
    <GestureDetector gesture={gesture}>
      <View style={{ flex: 1 }}>
        <Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
          {children}
        </Canvas>
      </View>
    </GestureDetector>
  );
}

This targets Gesture Handler 3's hook API, like the rest of the React Native guides. On Gesture Handler 2, the same callbacks hang off the Gesture.Pan() builder.

Bend the Backdrop with a Lens Shader

The lens is a BackdropFilter inside a circular clip, so it only touches the pixels under the sphere. The shader samples the backdrop three times, once per colour channel, with slightly different offsets. That per-channel spread is the dispersion that makes the rim read as glass rather than a magnifier.

Four uniforms shape the lens, and all of them change with the radius, so the button is a thicker lens than the dome:

UniformAt the buttonAt the dome
amount (thickness)0.620.42
bezel (share of the radius that is bevel)0.420.25
disp (per-channel spread)0.12Sky 0.05, Astro 0
slosh0.10 · radius times the smoothed motion, applied at the centre and not at the rimsame

Magnification is amount · (0.42 + 0.58 · bezel²), so the interior magnifies and the bevel shears. The slosh drags the picture inside the sphere a beat behind the glass, which is what makes the glass read as liquid. Astro sets dispersion to zero at the dome because a star split into three coloured dots stops reading as a star.

import { Skia } from "@shopify/react-native-skia";

export const lensSource = Skia.RuntimeEffect.Make(`
uniform shader image;
uniform float2 center;
uniform float radius;
uniform float amount;
uniform float bezel;
uniform float disp;
uniform float2 slosh;

half4 main(float2 xy) {
  float2 d = (xy - center) / radius;
  float rr = length(d);
  if (rr >= 1.0) return image.eval(xy);

  float bevelMix = smoothstep(1.0 - bezel, 1.0, rr);
  float k = amount * (0.42 + 0.58 * bevelMix * bevelMix);
  float2 pull = (xy - center) * k + slosh * (1.0 - rr * rr);

  half4 r = image.eval(xy - pull * (1.0 + disp));
  half4 g = image.eval(xy - pull);
  half4 b = image.eval(xy - pull * (1.0 - disp));
  return half4(r.r, g.g, b.b, max(g.a, max(r.a, b.a)));
}`)!;

Mount it with a clip that follows the sphere, and rebuild the uniforms on the UI thread with useDerivedValue so no frame crosses to JavaScript:

import { BackdropFilter, Group, RuntimeShader, Skia } from "@shopify/react-native-skia";
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
import type { SkRuntimeEffect } from "@shopify/react-native-skia";

type LensProps = {
  source: SkRuntimeEffect;
  centerX: number;
  centerY: SharedValue<number>;
  radius: SharedValue<number>;
};

export function SphereLens({ source, centerX, centerY, radius }: LensProps) {
  const clip = useDerivedValue(() => {
    const r = radius.value;
    return Skia.RRectXY(Skia.XYWHRect(centerX - r, centerY.value - r, r * 2, r * 2), r, r);
  });

  const uniforms = useDerivedValue(() => ({
    center: [centerX, centerY.value],
    radius: radius.value,
    amount: 0.42,
    bezel: 0.25,
    disp: 0.05,
    slosh: [0, 0],
  }));

  return (
    <Group clip={clip}>
      <BackdropFilter filter={<RuntimeShader source={source} uniforms={uniforms} />} />
    </Group>
  );
}

RuntimeEffect.Make returns null when the SkSL does not compile. The non-null assertion turns that into a crash at import time; during development, branch on it and log the source instead.

Light the Glass from Force, Not Speed

The glass shader draws the body, the rim, the sheen, the halo, and the caustic. The caustic is a soft lavender bloom that focuses into a bowl of cyan-to-blue light with a thread of gold when the sphere is pushed hard. It answers force, so a landing floods the crown and a throw in free flight stays dark.

  • While the finger is down, the target is min(1, |force| / 430), where force is the finger's velocity.
  • In free flight, the target is min(1, |deceleration| / 12000), and only while the sphere is slowing.
  • The glow eases toward the target at 22 percent per frame rising and 12 percent falling.
  • The light gathers at the crown. A sideways shove tilts it by at most 0.42 of the unit direction, about 25 degrees. It drops to the bottom only when pulled straight down.
  • The caustic belongs to the big glass. Fade it out between radii 190 sx and 100 sx, so the button never shows it.

Compute all of this in one useFrameCallback. Read velocity from the gesture while touching, and from the change in centre position otherwise. Skip the first few frames while the layout settles, or the initial jump in position reads as a shove.

Emit the Sticker Plume

The plume starts 350 ms after landing, one sticker every 30 ms. Four in five stickers rise through the button's own glass at scale 0.34, so the lens magnifies and splits them on the way out. One in five surfaces as a speck above it. Each sticker has a home in a plume that narrows at the base and opens as it climbs.

The field runs as a small physics system on the UI thread:

  • buoyancy and wander,
  • a spring toward each sticker's home,
  • neighbour repulsion,
  • finger repulsion within 170 pt, and
  • a wind at 9 percent of the finger's velocity.

The two cookbooks leave differently, and the difference is the lesson. Sky's stickers get a shove and fall under gravity. Once the dome is home, buoyancy takes over again and they drift back up while they fade. Astro's stickers ride a log spiral into a drain at the bottom centre, shed stardust above a speed threshold, and flare the glow layer as they enter. Pick the exit that matches the backdrop: a fall belongs to a sky, a vortex belongs to a night.

Keep every sticker slot in one typed array on a shared value and update it in place. Forty sprites through React state would drop frames; forty sprites through a worklet do not.

Wipe the Copy In

The copy uses blur and opacity, never travel, so it reads as focus rather than motion:

ElementMotion
Wordmarkfades between p = 0.08 and 0.62, defocus 0 to 13
Hintfades between p = 0.06 and 0.30, carried 0.04 · radius with the slosh
Headline and pill520 ms bezier(0.23, 1, 0.32, 1) in after landing, 240 ms out
Third line, ina positional blur wipe, left to right, 560 ms
Third line, hold1950 ms, then 400 ms out with blur leading opacity, then a 460 ms gap

Under reduced motion, skip the wipe and show the line in place.

Keep It Accessible

  • Honour useReducedMotion. The landing spring loses its bounce ({ damping: 30, stiffness: 160 }) and the third line fades instead of wiping.
  • Put the swipe hint on the page as its accessibilityLabel, with an accessibilityHint that names both directions.
  • Keep the pill inert and out of the accessibility tree until it has appeared. Toggle accessible and pointerEvents from a reaction on the shown value.
  • Give the pill accessibilityRole="button" and a label equal to its text.
  • Provide the action without the gesture. Deep-linking with initialState="open" is the deterministic state for screenshots and for anyone who cannot swipe.

Integrate into an Existing App

  1. Decide how the app will use the study's code before you read it. Its code is GPL-3.0. An app that copies any of it must ship under GPL-3.0 terms, or it must not copy it. For a proprietary app, treat the repository as a reference only and write the implementation independently, as the samples in this guide do.
  2. Confirm the installed Expo SDK, React Native version, and router. The study targets Expo SDK 57, React Native 0.86, React Native Skia 2.6, and Reanimated 4.5.
  3. Install the missing packages with npx expo install: @shopify/react-native-skia, react-native-reanimated, react-native-worklets, react-native-gesture-handler, expo-blur, expo-image, expo-status-bar, and expo-asset. Then rebuild the native app.
  4. Confirm the worklets Babel plugin is active. babel-preset-expo adds react-native-worklets/plugin when the package is installed. A custom babel.config.js must list the plugin itself, last in the list, or the app builds and no animation runs.
  5. Preload the backdrop, the wordmark, and the stickers before you reveal the React tree. Adapt the loading pattern to the app's root layout instead of replacing it.
  6. Mount the screen inside a full-height flex: 1 surface with no header and no safe-area padding. The app root needs a GestureHandlerRootView, and the screen needs the GestureDetector wrapper shown above. The screen owns its canvas and its status bar style.
  7. Wire the primary action to the app's own onboarding route.
  8. Validate the swipe up, a partial drag that springs back, the swipe down, the primary action, and reduced motion, on an iPhone simulator and one Android viewport, in a development build.

The repository publishes a copy-paste prompt for each cookbook that lists the exact files, assets, packages, and semantic actions an agent must inspect. That prompt copies the GPL-3.0 component into the target app, so use it only when the app can accept those license terms.

Replace the Identity Before Release

Two separate obligations apply, and changing the identity satisfies only the second.

The code license comes first. The study's code is GPL-3.0. Code copied from it stays GPL-3.0 inside your app, whatever you rename or restyle, so a proprietary app must not copy it. Write your own implementation from the public documentation, or accept the GPL-3.0 terms for the app that ships it.

The design is a study of a publicly visible interaction, and its notice asks for a new identity before any public or commercial use:

  • Replace the wordmarks, stickers, copy, and backdrops with your own authorized artwork. The prompts that generated the originals are published so you can make a set in your own identity.
  • Design your own colours, typography, spacing, composition, and motion language. Changing only a wordmark or an accent colour is not enough.
  • Remove anything that implies affiliation with a referenced company.
  • Read the repository's NOTICE.md and obtain any clearance your use and jurisdiction require.

Sources

The geometry, thresholds, and physics above come from the study's motion specification, its README, and its intellectual-property notice. The code in this guide is written independently against the public Skia BackdropFilter, Skia runtime shader, Reanimated useDerivedValue, Reanimated useFrameCallback, and Gesture Handler pan documentation.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "liquid-glass-welcome-screen" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems