# Predictive Back

> Android's predictive back gesture — drag from the edge and the screen shrinks in your hand, revealing where you'll land before you commit.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/predictive-back).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/predictive-back
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/predictive-back-light-823500c91234e47e.png
- Registry item: https://uicapsule.com/r/predictive-back.json
- Categories: navigation, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/predictive-back.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `/phone-frame.tsx`

```tsx
import type { ReactNode } from "react";

// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.
export const SCREEN_WIDTH = 330;
export const SCREEN_HEIGHT = 716;

interface Clay {
  base: string;
  deep: string;
  light: string;
  /** RGB triplet for inner shading, tinted so each tone reads as one material. */
  rim: string;
}

// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.
const TONES = {
  espresso: { base: "#3a2b24", deep: "#1f1612", light: "#5a4436", rim: "32 20 14" },
  graphite: { base: "#303036", deep: "#18181c", light: "#4a4a52", rim: "10 10 14" },
  moss: { base: "#2f3a2c", deep: "#171d15", light: "#4a5a44", rim: "12 18 10" },
  ochre: { base: "#4a3c20", deep: "#261e0e", light: "#6e5a30", rim: "30 22 6" },
  plum: { base: "#3c2a3a", deep: "#1e141d", light: "#5c4058", rim: "26 12 24" },
  slate: { base: "#2a3448", deep: "#141a26", light: "#435270", rim: "10 16 30" },
  teal: { base: "#233a3a", deep: "#101d1d", light: "#38585a", rim: "6 22 22" },
  terracotta: { base: "#4a2c22", deep: "#26150f", light: "#6c4232", rim: "34 14 8" },
} satisfies Record<string, Clay>;

export type ClayTone = keyof typeof TONES;

const shellShadow = (rim: string) =>
  [
    "0 60px 100px -40px rgb(0 0 0 / 0.85)",
    "0 26px 40px -24px rgb(0 0 0 / 0.7)",
    "inset 0 6px 6px rgb(255 255 255 / 0.16)",
    `inset 0 -14px 22px rgb(${rim} / 0.75)`,
    "inset 12px 0 18px -12px rgb(255 255 255 / 0.1)",
    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,
  ].join(", ");

const Nub = ({ className, clay }: { className: string; clay: Clay }) => (
  <span
    aria-hidden="true"
    className={`absolute w-[9px] rounded-full ${className}`}
    style={{
      background: clay.base,
      boxShadow: `inset 2px 2px 2px rgb(255 255 255 / 0.14), inset -2px -2px 3px rgb(${clay.rim} / 0.7), 0 2px 4px rgb(0 0 0 / 0.5)`,
    }}
  />
);

interface PhoneFrameProps {
  children: ReactNode;
  /** Classes for the screen: background, text color, pointer handling. */
  className?: string;
  /** Hide the Dynamic Island when the screen draws its own status area. */
  island?: boolean;
  tone?: ClayTone;
}

export const PhoneFrame = ({
  children,
  className,
  island = true,
  tone = "graphite",
}: PhoneFrameProps) => {
  const clay = TONES[tone];
  return (
    <div
      className="relative shrink-0 rounded-[66px] p-[16px]"
      style={{
        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,
        boxShadow: shellShadow(clay.rim),
      }}
    >
      <Nub clay={clay} className="top-[132px] -left-[6px] h-[36px]" />
      <Nub clay={clay} className="top-[188px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[262px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[206px] -right-[6px] h-[92px]" />
      <div
        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? "bg-black"}`}
        style={{
          boxShadow: `0 0 0 2px rgb(${clay.rim} / 0.9), 0 -2px 3px rgb(${clay.rim} / 0.8), 0 3px 4px rgb(255 255 255 / 0.12)`,
          height: SCREEN_HEIGHT,
          width: SCREEN_WIDTH,
        }}
      >
        {children}
        {island && (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black"
          />
        )}
      </div>
    </div>
  );
};
```

### `/predictive-back.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import type { PointerEvent } from "react";
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
} from "motion/react";
import { PhoneFrame, SCREEN_HEIGHT, SCREEN_WIDTH } from "./phone-frame";

const EDGE = 28;
const PEEK = 220;
const COMMIT = 110;
const EXIT = SCREEN_WIDTH + 48;
const ARROW_SIZE = 40;

// Apple's scroll-deceleration projection (rate 0.995): a flick commits even from a short pull.
const project = (velocity: number) => ((velocity / 1000) * 0.995) / (1 - 0.995);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

interface Grab {
  pointerId: number;
  originX: number;
  originOffset: number;
  originY: number;
  top: number;
  unit: number;
}

const STORIES = [
  { blurb: "Why every good gesture has a click", hue: "#f97316", title: "Detents, explained" },
  { blurb: "One curve to rule the capsule library", hue: "#a855f7", title: "The spring config" },
  {
    blurb: "Predictive back and the end of mystery navigation",
    hue: "#e0673a",
    title: "Edge peeks",
  },
  { blurb: "Faking depth without WebGL", hue: "#10b981", title: "Phosphor & fog" },
  { blurb: "Rubber-banding at the edge of the world", hue: "#ef4444", title: "Overscroll" },
  { blurb: "Designing for the thumb, not the cursor", hue: "#eab308", title: "Reach zones" },
];

const TARGET = 2;

export const PredictiveBack = () => {
  const [screen, setScreen] = useState<"detail" | "home">("detail");
  const reduced = useReducedMotion() ?? false;
  const grabRef = useRef<Grab | null>(null);

  // 0 = detail at rest, EXIT = detail gone. The pointer writes it 1:1; springs settle it.
  const offset = useMotionValue(0);
  const lift = useMotionValue(0);
  // The arrow follows the live gesture only, so settle and reopen springs never flash it.
  const arrow = useMotionValue(0);
  const arrowY = useMotionValue(SCREEN_HEIGHT / 2);

  const detailScale = useTransform(offset, [0, PEEK], [1, 0.86]);
  const homeScale = useTransform(offset, [0, EXIT], [0.92, 1]);
  const homeX = useTransform(offset, [0, EXIT], [-20, 0]);
  const homeDim = useTransform(offset, [0, EXIT], [0.6, 0]);

  const arrowX = useTransform(arrow, [0, PEEK], [-ARROW_SIZE, 18]);
  const arrowOpacity = useTransform(arrow, [0, 24], [0, 1]);
  const armed = useTransform(arrow, [0, COMMIT - 1, COMMIT], [0.55, 0.9, 1.12]);
  const arrowScale = useSpring(armed, { damping: 18, stiffness: 520 });
  const arrowFill = useTransform(
    arrow,
    [COMMIT - 1, COMMIT],
    ["rgba(255,255,255,0.16)", "rgba(255,255,255,1)"],
  );
  const arrowInk = useTransform(arrow, [COMMIT - 1, COMMIT], ["#ffffff", "#0b0c10"]);
  const arrowTop = useTransform(arrowY, (y) => y - ARROW_SIZE / 2);

  const settle = (target: number, velocity: number) => {
    const done = () => {
      if (target === EXIT) {
        setScreen("home");
      }
    };
    if (reduced) {
      offset.jump(target);
      lift.jump(0);
      done();
      return;
    }
    void animate(lift, 0, { bounce: 0, type: "spring", visualDuration: 0.3 });
    void animate(offset, target, {
      bounce: target === 0 ? 0.15 : 0,
      onComplete: done,
      type: "spring",
      velocity,
      visualDuration: 0.36,
    });
  };

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const unit = rect.width / SCREEN_WIDTH;
    const localX = (event.clientX - rect.left) / unit;
    const onButton = event.target instanceof Element && event.target.closest("button") !== null;
    const inFlight = offset.isAnimating();
    const fromEdge = screen === "detail" && localX < EDGE && !onButton;
    if (!inFlight && !fromEdge) {
      return;
    }
    offset.stop();
    lift.stop();
    arrow.stop();
    event.currentTarget.setPointerCapture(event.pointerId);
    const y = clamp((event.clientY - rect.top) / unit, 90, SCREEN_HEIGHT - 90);
    grabRef.current = {
      originOffset: offset.get(),
      originX: event.clientX,
      originY: y,
      pointerId: event.pointerId,
      top: rect.top,
      unit,
    };
    arrowY.set(y);
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const grab = grabRef.current;
    if (!grab || grab.pointerId !== event.pointerId) {
      return;
    }
    const next = clamp(grab.originOffset + (event.clientX - grab.originX) / grab.unit, 0, EXIT);
    const y = clamp((event.clientY - grab.top) / grab.unit, 90, SCREEN_HEIGHT - 90);
    offset.set(next);
    arrow.set(next);
    arrowY.set(y);
    lift.set((y - grab.originY) * 0.12);
  };

  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
    const grab = grabRef.current;
    if (!grab || grab.pointerId !== event.pointerId) {
      return;
    }
    grabRef.current = null;
    const velocity = offset.getVelocity();
    const commit = offset.get() + project(velocity) > COMMIT;
    if (reduced) {
      arrow.jump(0);
    } else {
      void animate(arrow, 0, { bounce: 0, type: "spring", visualDuration: 0.22 });
    }
    settle(commit ? EXIT : 0, velocity);
  };

  const openDetail = () => {
    setScreen("detail");
    settle(0, offset.getVelocity());
  };

  return (
    <PhoneFrame className="bg-black" tone="terracotta">
      <div
        className="absolute inset-0 touch-none"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
      >
        <motion.div
          aria-hidden={screen === "detail"}
          style={{ scale: homeScale, x: homeX }}
          className="absolute inset-0 bg-[#140e0c] px-5 pt-[58px]"
        >
          <p className="text-[11px] font-semibold tracking-[0.14em] text-white/40 uppercase">
            Thursday, September 24
          </p>
          <p className="mt-1 text-[32px] leading-none font-bold tracking-tight text-white">
            Reader
          </p>
          <div className="mt-6 space-y-2.5">
            {STORIES.map((story, index) => (
              <button
                type="button"
                key={story.title}
                tabIndex={screen === "home" ? 0 : -1}
                onClick={index === TARGET ? openDetail : undefined}
                className={`flex w-full items-center gap-3.5 rounded-[20px] p-2.5 text-left ring-1 transition-colors ${
                  index === TARGET
                    ? "bg-[#3a1f16] ring-[#e0673a]/40 hover:bg-[#43241a]"
                    : "bg-white/[0.04] ring-white/[0.05]"
                }`}
              >
                <span
                  aria-hidden="true"
                  className="size-[52px] shrink-0 rounded-[14px]"
                  style={{
                    background: `radial-gradient(circle at 30% 30%, ${story.hue} 0%, #1a1311 85%)`,
                  }}
                />
                <span className="min-w-0 flex-1">
                  <span className="block text-[14px] font-semibold text-white">{story.title}</span>
                  <span className="mt-0.5 block truncate text-[12px] text-white/45">
                    {story.blurb}
                  </span>
                </span>
                {index === TARGET && (
                  <ChevronRight className="mr-1 size-4 shrink-0 text-white/40" />
                )}
              </button>
            ))}
          </div>
          <p className="absolute inset-x-0 bottom-9 text-center text-[11px] text-white/30">
            Open the highlighted story
          </p>
          <motion.div
            aria-hidden="true"
            style={{ opacity: homeDim }}
            className="pointer-events-none absolute inset-0 bg-black"
          />
        </motion.div>

        <motion.article
          aria-hidden={screen === "home"}
          style={{ scale: detailScale, x: offset, y: lift }}
          className="absolute inset-0 overflow-hidden rounded-[50px] bg-[#1a1310] shadow-[0_24px_60px_rgb(0_0_0/0.7)]"
        >
          <div className="relative h-[292px] overflow-hidden bg-[radial-gradient(circle_at_28%_38%,#fdba74_0%,#e0673a_28%,#44211a_70%)]">
            <div className="absolute -right-10 bottom-[-60px] size-[220px] rounded-full bg-[#2a120c]/50 blur-[2px]" />
            <div className="absolute top-[120px] left-[150px] size-[64px] rounded-full bg-white/15 ring-1 ring-white/25" />
            <div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-[#1a1310] to-transparent" />
            <button
              type="button"
              aria-label="Back"
              tabIndex={screen === "detail" ? 0 : -1}
              onClick={() => settle(EXIT, 0)}
              className="absolute top-[54px] left-5 grid size-9 place-items-center rounded-full bg-black/30 text-white backdrop-blur-md transition-colors hover:bg-black/45"
            >
              <ChevronLeft className="size-5" />
            </button>
          </div>
          <div className="relative -mt-6 px-6">
            <p className="text-[11px] font-semibold tracking-[0.16em] text-orange-300 uppercase">
              Navigation
            </p>
            <p className="mt-2 text-[24px] leading-[1.15] font-bold tracking-tight text-white">
              Edge peeks: predictive back and the end of mystery navigation
            </p>
            <div className="mt-4 flex items-center gap-2.5">
              <span className="size-7 rounded-full bg-[linear-gradient(135deg,#f472b6,#6366f1)]" />
              <p className="text-[12px] text-white/50">Mara Okafor · 6 min read</p>
            </div>
            <p className="mt-4 text-[14px] leading-relaxed text-white/65">
              Android 14 made the back gesture honest. Pull from the left edge and the current
              screen shrinks in your hand, revealing exactly where you&apos;ll land before you
              commit.
            </p>
            <p className="mt-3 text-[14px] leading-relaxed text-white/65">
              Let go early and it springs back. Flick and it&apos;s gone. No leap of faith.
            </p>
          </div>
          <div className="absolute inset-x-0 bottom-0 flex h-28 items-end justify-center bg-gradient-to-t from-[#1a1310] via-[#1a1310]/90 to-transparent pb-9">
            <p className="text-[11px] text-white/30">Drag from the left edge</p>
          </div>
          <span
            aria-hidden="true"
            className="absolute top-1/2 left-1.5 h-12 w-1 -translate-y-1/2 rounded-full bg-white/20"
          />
        </motion.article>

        <motion.div
          aria-hidden="true"
          style={{
            backgroundColor: arrowFill,
            color: arrowInk,
            height: ARROW_SIZE,
            opacity: arrowOpacity,
            scale: arrowScale,
            width: ARROW_SIZE,
            x: arrowX,
            y: arrowTop,
          }}
          className="pointer-events-none absolute top-0 left-0 z-40 grid place-items-center rounded-full shadow-lg shadow-black/40 backdrop-blur-md"
        >
          <ArrowLeft className="size-[18px]" />
        </motion.div>
      </div>
    </PhoneFrame>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import type { CSSProperties } from "react";

import { PredictiveBack } from "./predictive-back";

// Light pools on the left, the edge the back gesture pulls from.
const backdrop: CSSProperties = {
  background: [
    "radial-gradient(ellipse 45% 80% at 0% 50%, rgb(176 82 48 / 0.4), transparent 70%)",
    "radial-gradient(ellipse 50% 55% at 50% 45%, rgb(110 48 30 / 0.35), transparent 72%)",
    "linear-gradient(100deg, #3a1a10 0%, #26110b 45%, #140806 100%)",
  ].join(", "),
};

const Preview = () => (
  <main className="flex h-dvh items-center justify-center overflow-hidden p-5" style={backdrop}>
    <PredictiveBack />
  </main>
);

export default Preview;
```
