# Confirm Button: a liquid two-step delete for React Native, and seven Skia traps on the web

> How Confirm Button draws a liquid cancel bubble with Skia geometry and a Reanimated state machine, and seven traps of React Native Skia on the web.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Confirm Button — Two-Step Delete & Hold-to-Confirm Button for React Native & Expo](https://upfork.dev/products/confirm-button-react-native) (Mobile component, Free). Published 2026-10-26. 10 min read. Written with AI: Claude Opus 5.5, through Claude.

Confirm Button is a button for actions people must not trigger by accident, for React Native and Expo: delete an account, remove an item, send money. One press arms it: the label changes ("Delete account" becomes "Tap to confirm") and a round cancel bubble buds off its side on a liquid neck that stretches, thins and snaps. A second press confirms, through busy (your promise), done and error. Left alone it disarms itself, with a countdown round the bubble; or, in `hold` mode, a liquid fill sweeps across while you hold.

[Live preview of Confirm Button — Two-Step Delete & Hold-to-Confirm Button for React Native & Expo](https://upfork.dev/products/confirm-button-react-native)

This article is for anyone drawing with React Native Skia who wants it to look the same on a phone and in a browser. It explains the state machine, the geometry of the neck and how the shapes are drawn, then the seven traps of Skia on the web that this build and its siblings in the same kit met, each with its fix.

## A state machine, and a countdown that is a timer

`useConfirm()` is the logic without the visuals: `idle → armed → busy → done | error → idle`, with `arm()`, `cancel()`, `confirm()`, `pause()` and `resume()`. While armed, a shared value `countdown` runs from 1 to 0 over `armTimeout` (4 s by default); when it reaches 0 the button disarms. A press on the pill pauses it, and a release resumes it with the time that was left. The countdown ring round the bubble draws that same value, so what you see is the timer itself.

`components/confirm-button/use-confirm.ts`, lines 81–93:

```ts
  const run = useCallback(
    (ms: number) => {
      if (armTimeout <= 0) return;
      cancelAnimation(countdown);
      const from = countdown.value;
      left.current = ms;
      // A timer, not motion: with reduce motion on, Reanimated would otherwise finish it at once (and disarm).
      countdown.value = withTiming(0, { duration: Math.max(1, ms * from), easing: linear, reduceMotion: ReduceMotion.Never }, (finished) => {
        if (finished) scheduleOnRN(disarm);
      });
    },
    [armTimeout, countdown, disarm],
  );
```

A Reanimated timing makes a good timer: it runs on the UI thread, it can be cancelled and restarted from where it stopped, and its callback fires when it finishes. It has one catch.

> **Lesson: Reanimated finishes timings at once under reduce motion: a timing used as a timer needs ReduceMotion.Never** (Not in the docs; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5)
> **I assumed:** reduce motion would only calm the motion; the countdown that disarms the button would still take its four seconds.
> **What happened:** with `prefers-reduced-motion: reduce` emulated in Chrome, a click on "Sign out of all devices" left its label unchanged: the button armed and disarmed in the same moment. A timing's default, `ReduceMotion.System`, finishes it at once when the system asks for reduced motion, so the countdown's callback ran straight away. Reanimated says so in a development warning: "Reduced motion setting is enabled on this device… Some animations will be disabled by default."
> **The fix:** the countdown and the hold fill pass `reduceMotion: ReduceMotion.Never`, while real motion (the neck, the error shake) still follows the setting. The same test then read "Sign out of all devices, armed. Activate again to confirm". The sibling Toast Stack had the same bug in each toast's life: measured with reduced motion on, a 4 s toast lasted 117 ms before the fix and 4.08 s after.
> **Evidence:** the reduced-motion test's output before and after; use-confirm.ts line 88; confirm-button.tsx lines 456–457; commit 03257ac (Toast Stack).

## One liquid: the pill, the neck and the bubble

The bubble is a circle the height of the button, and the pill's end is a circle of the same radius, `r`. The neck between them is two cubic curves, each leaving one circle and arriving at the other along their tangents, so the joins are smooth and the edges are as crisp as the pill's: no blur-and-threshold goo filter.

Where the neck takes hold is worked out from the two circles, with centres `d` apart:

- while they still overlap (`d < r1 + r2`), the angle where they cross, by the law of cosines: `u1 = acos((r1² + d² − r2²) / (2 · r1 · d))`;
- the angle of their outer tangent, the widest a neck could hold on: `acos((r1 − r2) / d)`;
- the neck's ends sit between the two, at a `spread` of `0.12 + 0.5 · waist`, and the curves' handles, along the tangents, grow as the waist pinches, "long enough to sag into a waist, never so long the curves cross".

`waist` runs from 1 (a full join) to 0 (a thread) as the gap between the circles grows: `1 − smoothstep(0, limit, gap)`.

`components/confirm-button/geometry.ts`, lines 17–60:

```ts
/**
 * Adds the neck between circle 1 (the pill's end cap) and circle 2 (the bubble). `waist` runs from 1 (a full,
 * settled join) to 0 (pinched to a thread): how far round each circle the neck takes hold, and how deep its curves
 * sag. Nothing is added while the circles still overlap enough to need none.
 */
export function addNeck(path: SkPathBuilder, x1: number, y1: number, r1: number, x2: number, y2: number, r2: number, waist: number) {
  'worklet';
  const d = Math.hypot(x2 - x1, y2 - y1);
  if (r1 <= 0 || r2 <= 0 || d <= Math.abs(r1 - r2) + 0.5 || d < 1e-3) return;
  // Where the circles cross, if they do: the neck starts at least there.
  let u1 = 0;
  let u2 = 0;
  if (d < r1 + r2) {
    u1 = Math.acos(clamp((r1 * r1 + d * d - r2 * r2) / (2 * r1 * d), -1, 1));
    u2 = Math.acos(clamp((r2 * r2 + d * d - r1 * r1) / (2 * r2 * d), -1, 1));
  }
  const toward = Math.atan2(y2 - y1, x2 - x1);
  // The widest the neck could hold on: the outer tangent's angle.
  const widest = Math.acos(clamp((r1 - r2) / d, -1, 1));
  const spread = 0.12 + 0.5 * waist;
  const a1 = toward + u1 + (widest - u1) * spread;
  const a2 = toward - u1 - (widest - u1) * spread;
  const a3 = toward + Math.PI - u2 - (Math.PI - u2 - widest) * spread;
  const a4 = toward - Math.PI + u2 + (Math.PI - u2 - widest) * spread;
  const p1x = x1 + Math.cos(a1) * r1;
  const p1y = y1 + Math.sin(a1) * r1;
  const p2x = x1 + Math.cos(a2) * r1;
  const p2y = y1 + Math.sin(a2) * r1;
  const p3x = x2 + Math.cos(a3) * r2;
  const p3y = y2 + Math.sin(a3) * r2;
  const p4x = x2 + Math.cos(a4) * r2;
  const p4y = y2 + Math.sin(a4) * r2;
  // Handle lengths: long enough to sag into a waist, never so long the curves cross.
  const span = Math.hypot(p3x - p1x, p3y - p1y);
  const handle = clamp(span / (r1 + r2), 0, 1) * (0.55 + 0.45 * (1 - waist)) * Math.min(1, (d * 2) / (r1 + r2));
  const h1 = r1 * handle;
  const h2 = r2 * handle;
  const q = Math.PI / 2;
  path.moveTo(p1x, p1y);
  path.cubicTo(p1x + Math.cos(a1 - q) * h1, p1y + Math.sin(a1 - q) * h1, p3x + Math.cos(a3 + q) * h2, p3y + Math.sin(a3 + q) * h2, p3x, p3y);
  path.lineTo(p4x, p4y);
  path.cubicTo(p4x + Math.cos(a4 - q) * h2, p4y + Math.sin(a4 - q) * h2, p2x + Math.cos(a2 + q) * h1, p2y + Math.sin(a2 + q) * h1, p2x, p2y);
  path.close();
}
```

Arming throws the bubble out past the breaking point (to 1.15 × the snap gap in 240 ms), then springs it back to rest 10 points away. The neck snaps once the gap passes 0.55 × the height, with a small wobble; cancelling reforms it only under 0.35 × the height. The two thresholds are a hysteresis, so a bubble hovering near one value never flickers between joined and broken.

`components/confirm-button/confirm-button.tsx`, lines 285–296:

```tsx
  // Snap and reform: the neck breaks once stretched past 0.55 × height, and comes back under 0.35 × height when merging.
  useAnimatedReaction(
    () => gap.value,
    (g) => {
      if (attached.value && !merging.value && g > snapGap) {
        attached.value = false;
        wobble.value = withSequence(withTiming(1.04, { duration: 70, easing: outCubic }), withSpring(1, { stiffness: 320, damping: 14 }));
      } else if (!attached.value && merging.value && g < reformGap) {
        attached.value = true;
      }
    },
  );
```

The first version built all of it as one path.

> **Lesson: Shapes with opposite windings cancel in one Skia path: draw the parts of a liquid shape as separate paths** (My own mistake; learned by the AI (Claude Opus 5.5); @shopify/react-native-skia@2.6.2)
> **I assumed:** the pill, the neck and the bubble could be one Skia path, filled once and shadowed once.
> **What happened:** where the neck's curves overlap the pill's end cap and the bubble, the contours wind in opposite directions, and under the default (nonzero) fill rule an overlap of opposite windings counts as outside: the liquid had holes where its parts overlapped.
> **The fix:** three paths, `pillShape`, `neckShape` and `bubbleShape`, drawn inside one `Group` whose layer carries the shadow. They are filled in one colour and shadowed once, as a single shape, and nothing cancels.
> **Evidence:** the build's slow-motion frames of the split; the comment at confirm-button.tsx lines 306–307; the kit README, "Two paths, not one, for liquid".

`components/confirm-button/confirm-button.tsx`, lines 306–336:

```tsx
  // Three shapes in one layer (one fill colour, one shadow): drawn as separate paths, so the neck's curves never
  // cancel the circles they overlap (one path with mixed windings leaves holes).
  const pillShape = useDerivedValue(() => {
    const path = Skia.PathBuilder.Make();
    const w = pillW.value;
    const x = ROOM + pillX.value + shake.value;
    const grow = (wobble.value - 1) * r;
    path.addRRect(Skia.RRectXY(Skia.XYWHRect(x - grow, ROOM - grow, w + grow * 2, H + grow * 2), r + grow, r + grow));
    return path.build();
  });
  const bubbleShape = useDerivedValue(() => {
    const path = Skia.PathBuilder.Make();
    if (bubbleShown.value <= 0.001 || (gap.value <= -2 * r + 0.5 && !reduced)) return path.build();
    const x = ROOM + pillX.value + shake.value;
    const cap = dir > 0 ? x + pillW.value - r : x + r;
    const bx = cap + dir * (gap.value + 2 * r);
    const br = r * wobble.value * (reduced ? 0.8 + 0.2 * bubbleShown.value : 1);
    path.addCircle(bx, ROOM + r, br);
    return path.build();
  });
  const neckShape = useDerivedValue(() => {
    const path = Skia.PathBuilder.Make();
    if (bubbleShown.value <= 0.001 || !attached.value || reduced) return path.build();
    const x = ROOM + pillX.value + shake.value;
    const cap = dir > 0 ? x + pillW.value - r : x + r;
    const bx = cap + dir * (gap.value + 2 * r);
    const limit = merging.value ? reformGap : snapGap;
    const waist = 1 - smoothstep(0, limit, gap.value);
    addNeck(path, cap, ROOM + r, r, bx, ROOM + r, r * wobble.value, waist);
    return path.build();
  });
```

## Warming the GPU before the first press

Measured on the production web build, the button ran at 60 fps except for the very first time it armed.

> **Lesson: The first concave path, circle and round-capped stroke each stall for a WebGL program: draw them unseen at mount** (The tools and the machine; learned by the AI (Claude Opus 5.5); @shopify/react-native-skia@2.6.2)
> **I assumed:** once the pill had been drawn, the first split would cost no more than any other frame.
> **What happened:** in Chrome on an Intel Iris Plus laptop, the first press that armed the button took one 66 ms frame; a second round on the same page had none. With a small concave path drawn from the start, the worst frames were still 66.1 and 49.5 ms, and a trace of the press showed 35.6 ms of a 67.5 ms task in `GLES2Implementation::GetProgramiv`: the page waiting on the GPU for a program it hadn't needed before. The bubble (a pure circle) and the countdown ring (a round-capped stroke) each have programs of their own.
> **The fix:** three small shapes are drawn under the opaque pill from the first frame, where no one sees them: a concave path, a circle and a round-capped arc. Three runs of the first arm then had worst frames of 17.1, 17.2 and 17.5 ms, none over 25 ms.
> **Evidence:** the build's frame-pacing runs and its trace; confirm-button.tsx lines 338–363 and 577–579; the kit README, "Warm every program".

`components/confirm-button/confirm-button.tsx`, lines 338–363:

```tsx
  // A small concave shape drawn under the pill from the start (the pill covers it): the GPU builds the program for
  // curved, concave paths now, not in the first frame of the first split (a 66 ms stall when it waited).
  const warm = useDerivedValue(() => {
    const path = Skia.PathBuilder.Make();
    const cx = ROOM + pillX.value + pillW.value / 2;
    const cy = ROOM + r;
    const s = r * 0.4;
    path.moveTo(cx - s, cy - s);
    path.cubicTo(cx, cy, cx, cy, cx + s, cy - s);
    path.lineTo(cx + s, cy + s);
    path.cubicTo(cx, cy, cx, cy, cx - s, cy + s);
    path.close();
    return path.build();
  });
  // The same for the bubble (a pure circle has its own program) and the countdown ring (a round-capped stroke).
  const warmCircle = useDerivedValue(() => {
    const path = Skia.PathBuilder.Make();
    path.addCircle(ROOM + pillX.value + pillW.value / 2, ROOM + r, r * 0.3);
    return path.build();
  });
  const warmArc = useDerivedValue(() => {
    const path = Skia.PathBuilder.Make();
    const cx = ROOM + pillX.value + pillW.value / 2;
    path.addArc(Skia.XYWHRect(cx - r * 0.3, ROOM + r * 0.7, r * 0.6, r * 0.6), -90, 300);
    return path.build();
  });
```

Focus Scroll, a sibling in the same kit, does the same for blur: the GPU compiles a blur program the first time it meets each blur size, so it draws every size once, invisibly, as it appears, at the screen's pixel ratio, since the sizes are in points and the GPU works in pixels.

## Colours: OKLab, then Display P3 on the web

The fill blends between the states' colours in OKLab, so a red on its way to near-black stays a darkening red instead of turning muddy brown, and a green on its way to white stays green. On the web, though, the colours came out wrong before any blending.

> **Lesson: React Native Skia's web canvas is Display P3 but draws the sRGB numbers it gets: saturated colours come out stronger** (Not in the docs; learned by the AI (Claude Opus 5.5); @shopify/react-native-skia@2.6.2)
> **I assumed:** a colour handed to a Skia `<Canvas>` shows on the web as the same sRGB colour it does in CSS.
> **What happened:** the danger red `#D7343A`, (215, 52, 58), came out as (234, 20, 49) in the screenshot, while the greys were exact. A plain square filled with the literal colour did the same, so it wasn't the blending or the shadow. On the web, React Native Skia's view sets the WebGL2 context's `drawingBufferColorSpace` to `"display-p3"` whenever it sizes its canvas (`SkiaPictureView.web.js`), and CanvasKit still writes the sRGB numbers it's given, so the browser reads them as P3 numbers, with wider primaries: more saturation. Greys are the same numbers in both spaces, so they don't move.
> **The fix:** on the web, Confirm Button turns its OKLab colours into linear sRGB, converts them with the linear sRGB to linear Display P3 matrix, and encodes the result; the same sample then read (215, 52, 58). Goal Ring and Gooey Tab Bar later got a general version, `WebColors`: the canvas's content is drawn into a layer whose colour filter does the same conversion in linear light, and only where the browser has P3 canvases (`'drawingBufferColorSpace' in WebGL2RenderingContext.prototype`). Goal Ring's `#22C55E`, which had shown as `#00C952`, then read `#22C55E`.
> **Evidence:** the build's pixel samples before and after; oklab.ts lines 34–60; commits 9604802 and 8f6724b; `lib/web-colors.tsx` in Goal Ring and Gooey Tab Bar.

`components/confirm-button/oklab.ts`, lines 34–60:

```ts
/**
 * OKLab to `rgba()`. `p3`: give the numbers in Display P3 instead of sRGB. React Native Skia's web canvas marks its
 * drawing buffer as Display P3 but draws the sRGB numbers it's given, so on the web every colour comes out more
 * saturated than asked for (a #D7343A red showed as #EA1431); in P3 numbers it shows as intended.
 */
export function fromLab(lab: Lab, alpha = 1, p3 = false) {
  'worklet';
  const l = Math.pow(lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2], 3);
  const m = Math.pow(lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2], 3);
  const s = Math.pow(lab[0] - 0.0894841775 * lab[1] - 1.291485548 * lab[2], 3);
  let lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
  let lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
  let lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
  if (p3) {
    // Linear sRGB to linear Display P3 (same white, wider primaries).
    const pr = 0.8224621 * lr + 0.177538 * lg + 0.0000001 * lb;
    const pg = 0.0331941 * lr + 0.9668058 * lg + 0.0000001 * lb;
    const pb = 0.0170827 * lr + 0.0723974 * lg + 0.9105199 * lb;
    lr = pr;
    lg = pg;
    lb = pb;
  }
  const r = toGamma(lr);
  const g = toGamma(lg);
  const b = toGamma(lb);
  return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})`;
}
```

## A canvas bigger than the button

The canvas reaches 28 points past the button on every side (`ROOM`), so the shadow and the countdown ring are never cut off. On a phone that costs nothing. In a browser it broke whatever sat beside the button.

> **Lesson: On the web, React Native Skia's canvas ignores pointerEvents none: a canvas past its box takes clicks beside it** (Not in the docs; learned by the AI (Claude Opus 5.5); @shopify/react-native-skia@2.6.2, react-native-web@0.21)
> **I assumed:** `pointerEvents="none"` on the Skia `Canvas` lets clicks through it, as it does on a `View`.
> **What happened:** in the transfer demo, the canvas's margin covered the switch that makes the next send fail. A click on the switch did nothing, and `document.elementFromPoint` at that spot returned the `<canvas>`.
> **The fix:** the canvas sits inside a `View` with `pointerEvents="none"`, which React Native Web does pass to the page. The click then toggled the switch, and `elementFromPoint` returned its `<input role="switch">`. Gooey Tab Bar had the same bug: its canvas's 284 px stage, room for the bubble and the bloom, made the lower half of the Food demo's Add button dead.
> **Evidence:** the build's click test before and after; confirm-button.tsx lines 565–568; commit d149b78 (Gooey Tab Bar).

`components/confirm-button/confirm-button.tsx`, lines 565–583:

```tsx
          {/* The canvas reaches past the button (room for the shadow and the ring): on the web it would take the
              clicks meant for whatever is beside it, so it sits in a box that lets every touch through. */}
          <View pointerEvents="none" style={[styles.room, { left: -ROOM, top: -ROOM, width: canvasW, height: canvasH }]}>
          <Canvas style={{ width: canvasW, height: canvasH }} pointerEvents="none">
            <Group
              layer={
                <Paint>
                  <Shadow dx={0} dy={8} blur={9} color={shadowColor} />
                  {palette.shadow.glow > 0 ? <Shadow dx={0} dy={0} blur={12} color={glowColor} /> : null}
                </Paint>
              }
            >
              <Path path={warm} color={color} />
              <Path path={warmCircle} color={color} />
              <Path path={warmArc} color={palette.ring} style="stroke" strokeWidth={2} strokeCap="round" start={0} end={0.8} />
              <Path path={pillShape} color={color} />
              <Path path={neckShape} color={color} />
              <Path path={bubbleShape} color={color} opacity={bubbleOpacity} />
            </Group>
```

## Skia on the web, in the rest of the kit

Two more traps hit every Skia product in the kit before any of them drew a pixel in a browser.

> **Lesson: React Native Skia on the web needs CanvasKit's bin/full build: the plain one fails with I.get is not a function** (Not in the docs; learned by the AI (Claude Opus 5.5); @shopify/react-native-skia@2.6.2)
> **I assumed:** the `canvaskit.wasm` from the `canvaskit-wasm` package would run React Native Skia on the web, whichever of its builds it was.
> **What happened:** the plain build failed with "I.get is not a function"; only the `bin/full` build works. And Skia's web module can't even be imported before CanvasKit has loaded, so the load has to finish before the first Skia import.
> **The fix:** the web entry calls `LoadSkiaWeb({ locateFile })` with `bin/full/canvaskit.wasm` copied to `public/`, and imports the app only once it has loaded. The kit's shell and UpFork's preview do this before any Skia demo, and every Skia product's README tells buyers how.
> **Evidence:** the first test of an Expo Skia demo on the web, before the kit was built; the comment on `canvasKitSource()` in the kit's scripts/index.mjs; the kit README, "Skia on the web"; this product's README, "Install".

> **Lesson: A 22 KB SkSL shader took 21 s to compile in Chrome on Windows: render art that never changes offline instead** (The tools and the machine; learned by the AI (Claude Opus 5.5); @shopify/react-native-skia@2.6.2, canvaskit-wasm@0.41.0)
> **I assumed:** Filmstrip's demo art, fourteen landscapes painted by one SkSL runtime shader, could be drawn in the browser when the demo opened: each picture is one draw.
> **What happened:** in Chrome on Windows, where WebGL runs through ANGLE on Direct3D 11, the first picture took 21,228 ms and the other thirteen 13 to 34 ms each: nearly all of it was compiling the 22,108-character shader before its first use.
> **The fix:** art that never changes is rendered once, offline. Filmstrip's `art/render.mjs` runs the same shader on the CPU with CanvasKit in Node (2 to 11 s a picture), and `art/webp.py` turns the results into WebP files of at most 120 KB. The demos ship the pictures, not the shader.
> **Evidence:** the build's per-scene paint log; Filmstrip commit 4ab1a83; the kit README, "Big runtime shaders compile slowly on Windows".

## Checked

All in Chrome on an Intel Iris Plus laptop (ANGLE on Direct3D 11), on the production web build:

- The first arm, after the warm-up: three runs, worst frames 17.1, 17.2 and 17.5 ms, none over 25 ms. Hold to send, in the transfer demo: worst frame 17.2 ms.
- Colour: the danger fill samples as (215, 52, 58), its own `#D7343A`; the greys are unchanged.
- Reduced motion: the button arms and stays armed, and the countdown and the hold fill still run.
- The kit's check: 532 KB of JavaScript gzipped, within its 600 KB budget, plus CanvasKit (3.12 MB gzipped), which UpFork shares between products and a visitor downloads once.

## Make it yours

- `mode="hold"` with `holdDuration` (1200 ms) for a press-and-hold; letting go early drains the fill twice as fast.
- `armTimeout` (4000 ms; `0` never disarms), `resetAfter` (1400 ms in done) and `persistDone`.
- `tone` (`danger`, `primary`, `neutral`), `size` (`sm` 36, `md` 48, `lg` 56 points), `fullWidth`, `align` and `icon`.
- `errorLabel` takes a string or a function of the error, so a rejected promise can show its own message.
- `useConfirm()` gives the same state machine and `countdown` for visuals of your own.
- On the web, Confirm Button converts its colours for a P3 canvas on every browser. The later `WebColors` layer in Goal Ring checks for P3 canvases first, which is the better model if you target browsers without them.

---

On UpFork: https://upfork.dev/articles/confirm-button-a-liquid-two-step-delete-for-react-native-and-seven-skia-trap
