{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["navigation","minimal"],"dependencies":["lucide-react","motion"],"description":"Android's predictive back gesture — drag from the edge and the screen shrinks in your hand, revealing where you'll land before you commit.","devDependencies":[],"files":[{"content":"import type { ReactNode } from \"react\";\n\n// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.\nexport const SCREEN_WIDTH = 330;\nexport const SCREEN_HEIGHT = 716;\n\ninterface Clay {\n  base: string;\n  deep: string;\n  light: string;\n  /** RGB triplet for inner shading, tinted so each tone reads as one material. */\n  rim: string;\n}\n\n// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.\nconst TONES = {\n  espresso: { base: \"#3a2b24\", deep: \"#1f1612\", light: \"#5a4436\", rim: \"32 20 14\" },\n  graphite: { base: \"#303036\", deep: \"#18181c\", light: \"#4a4a52\", rim: \"10 10 14\" },\n  moss: { base: \"#2f3a2c\", deep: \"#171d15\", light: \"#4a5a44\", rim: \"12 18 10\" },\n  ochre: { base: \"#4a3c20\", deep: \"#261e0e\", light: \"#6e5a30\", rim: \"30 22 6\" },\n  plum: { base: \"#3c2a3a\", deep: \"#1e141d\", light: \"#5c4058\", rim: \"26 12 24\" },\n  slate: { base: \"#2a3448\", deep: \"#141a26\", light: \"#435270\", rim: \"10 16 30\" },\n  teal: { base: \"#233a3a\", deep: \"#101d1d\", light: \"#38585a\", rim: \"6 22 22\" },\n  terracotta: { base: \"#4a2c22\", deep: \"#26150f\", light: \"#6c4232\", rim: \"34 14 8\" },\n} satisfies Record<string, Clay>;\n\nexport type ClayTone = keyof typeof TONES;\n\nconst shellShadow = (rim: string) =>\n  [\n    \"0 60px 100px -40px rgb(0 0 0 / 0.85)\",\n    \"0 26px 40px -24px rgb(0 0 0 / 0.7)\",\n    \"inset 0 6px 6px rgb(255 255 255 / 0.16)\",\n    `inset 0 -14px 22px rgb(${rim} / 0.75)`,\n    \"inset 12px 0 18px -12px rgb(255 255 255 / 0.1)\",\n    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,\n  ].join(\", \");\n\nconst Nub = ({ className, clay }: { className: string; clay: Clay }) => (\n  <span\n    aria-hidden=\"true\"\n    className={`absolute w-[9px] rounded-full ${className}`}\n    style={{\n      background: clay.base,\n      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)`,\n    }}\n  />\n);\n\ninterface PhoneFrameProps {\n  children: ReactNode;\n  /** Classes for the screen: background, text color, pointer handling. */\n  className?: string;\n  /** Hide the Dynamic Island when the screen draws its own status area. */\n  island?: boolean;\n  tone?: ClayTone;\n}\n\nexport const PhoneFrame = ({\n  children,\n  className,\n  island = true,\n  tone = \"graphite\",\n}: PhoneFrameProps) => {\n  const clay = TONES[tone];\n  return (\n    <div\n      className=\"relative shrink-0 rounded-[66px] p-[16px]\"\n      style={{\n        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,\n        boxShadow: shellShadow(clay.rim),\n      }}\n    >\n      <Nub clay={clay} className=\"top-[132px] -left-[6px] h-[36px]\" />\n      <Nub clay={clay} className=\"top-[188px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[262px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[206px] -right-[6px] h-[92px]\" />\n      <div\n        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? \"bg-black\"}`}\n        style={{\n          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)`,\n          height: SCREEN_HEIGHT,\n          width: SCREEN_WIDTH,\n        }}\n      >\n        {children}\n        {island && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black\"\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n","path":"/phone-frame.tsx","target":"uicapsule/predictive-back/phone-frame.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport type { PointerEvent } from \"react\";\nimport { ArrowLeft, ChevronLeft, ChevronRight } from \"lucide-react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { PhoneFrame, SCREEN_HEIGHT, SCREEN_WIDTH } from \"./phone-frame\";\n\nconst EDGE = 28;\nconst PEEK = 220;\nconst COMMIT = 110;\nconst EXIT = SCREEN_WIDTH + 48;\nconst ARROW_SIZE = 40;\n\n// Apple's scroll-deceleration projection (rate 0.995): a flick commits even from a short pull.\nconst project = (velocity: number) => ((velocity / 1000) * 0.995) / (1 - 0.995);\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n\ninterface Grab {\n  pointerId: number;\n  originX: number;\n  originOffset: number;\n  originY: number;\n  top: number;\n  unit: number;\n}\n\nconst STORIES = [\n  { blurb: \"Why every good gesture has a click\", hue: \"#f97316\", title: \"Detents, explained\" },\n  { blurb: \"One curve to rule the capsule library\", hue: \"#a855f7\", title: \"The spring config\" },\n  {\n    blurb: \"Predictive back and the end of mystery navigation\",\n    hue: \"#e0673a\",\n    title: \"Edge peeks\",\n  },\n  { blurb: \"Faking depth without WebGL\", hue: \"#10b981\", title: \"Phosphor & fog\" },\n  { blurb: \"Rubber-banding at the edge of the world\", hue: \"#ef4444\", title: \"Overscroll\" },\n  { blurb: \"Designing for the thumb, not the cursor\", hue: \"#eab308\", title: \"Reach zones\" },\n];\n\nconst TARGET = 2;\n\nexport const PredictiveBack = () => {\n  const [screen, setScreen] = useState<\"detail\" | \"home\">(\"detail\");\n  const reduced = useReducedMotion() ?? false;\n  const grabRef = useRef<Grab | null>(null);\n\n  // 0 = detail at rest, EXIT = detail gone. The pointer writes it 1:1; springs settle it.\n  const offset = useMotionValue(0);\n  const lift = useMotionValue(0);\n  // The arrow follows the live gesture only, so settle and reopen springs never flash it.\n  const arrow = useMotionValue(0);\n  const arrowY = useMotionValue(SCREEN_HEIGHT / 2);\n\n  const detailScale = useTransform(offset, [0, PEEK], [1, 0.86]);\n  const homeScale = useTransform(offset, [0, EXIT], [0.92, 1]);\n  const homeX = useTransform(offset, [0, EXIT], [-20, 0]);\n  const homeDim = useTransform(offset, [0, EXIT], [0.6, 0]);\n\n  const arrowX = useTransform(arrow, [0, PEEK], [-ARROW_SIZE, 18]);\n  const arrowOpacity = useTransform(arrow, [0, 24], [0, 1]);\n  const armed = useTransform(arrow, [0, COMMIT - 1, COMMIT], [0.55, 0.9, 1.12]);\n  const arrowScale = useSpring(armed, { damping: 18, stiffness: 520 });\n  const arrowFill = useTransform(\n    arrow,\n    [COMMIT - 1, COMMIT],\n    [\"rgba(255,255,255,0.16)\", \"rgba(255,255,255,1)\"],\n  );\n  const arrowInk = useTransform(arrow, [COMMIT - 1, COMMIT], [\"#ffffff\", \"#0b0c10\"]);\n  const arrowTop = useTransform(arrowY, (y) => y - ARROW_SIZE / 2);\n\n  const settle = (target: number, velocity: number) => {\n    const done = () => {\n      if (target === EXIT) {\n        setScreen(\"home\");\n      }\n    };\n    if (reduced) {\n      offset.jump(target);\n      lift.jump(0);\n      done();\n      return;\n    }\n    void animate(lift, 0, { bounce: 0, type: \"spring\", visualDuration: 0.3 });\n    void animate(offset, target, {\n      bounce: target === 0 ? 0.15 : 0,\n      onComplete: done,\n      type: \"spring\",\n      velocity,\n      visualDuration: 0.36,\n    });\n  };\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const unit = rect.width / SCREEN_WIDTH;\n    const localX = (event.clientX - rect.left) / unit;\n    const onButton = event.target instanceof Element && event.target.closest(\"button\") !== null;\n    const inFlight = offset.isAnimating();\n    const fromEdge = screen === \"detail\" && localX < EDGE && !onButton;\n    if (!inFlight && !fromEdge) {\n      return;\n    }\n    offset.stop();\n    lift.stop();\n    arrow.stop();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const y = clamp((event.clientY - rect.top) / unit, 90, SCREEN_HEIGHT - 90);\n    grabRef.current = {\n      originOffset: offset.get(),\n      originX: event.clientX,\n      originY: y,\n      pointerId: event.pointerId,\n      top: rect.top,\n      unit,\n    };\n    arrowY.set(y);\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const grab = grabRef.current;\n    if (!grab || grab.pointerId !== event.pointerId) {\n      return;\n    }\n    const next = clamp(grab.originOffset + (event.clientX - grab.originX) / grab.unit, 0, EXIT);\n    const y = clamp((event.clientY - grab.top) / grab.unit, 90, SCREEN_HEIGHT - 90);\n    offset.set(next);\n    arrow.set(next);\n    arrowY.set(y);\n    lift.set((y - grab.originY) * 0.12);\n  };\n\n  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    const grab = grabRef.current;\n    if (!grab || grab.pointerId !== event.pointerId) {\n      return;\n    }\n    grabRef.current = null;\n    const velocity = offset.getVelocity();\n    const commit = offset.get() + project(velocity) > COMMIT;\n    if (reduced) {\n      arrow.jump(0);\n    } else {\n      void animate(arrow, 0, { bounce: 0, type: \"spring\", visualDuration: 0.22 });\n    }\n    settle(commit ? EXIT : 0, velocity);\n  };\n\n  const openDetail = () => {\n    setScreen(\"detail\");\n    settle(0, offset.getVelocity());\n  };\n\n  return (\n    <PhoneFrame className=\"bg-black\" tone=\"terracotta\">\n      <div\n        className=\"absolute inset-0 touch-none\"\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n      >\n        <motion.div\n          aria-hidden={screen === \"detail\"}\n          style={{ scale: homeScale, x: homeX }}\n          className=\"absolute inset-0 bg-[#140e0c] px-5 pt-[58px]\"\n        >\n          <p className=\"text-[11px] font-semibold tracking-[0.14em] text-white/40 uppercase\">\n            Thursday, September 24\n          </p>\n          <p className=\"mt-1 text-[32px] leading-none font-bold tracking-tight text-white\">\n            Reader\n          </p>\n          <div className=\"mt-6 space-y-2.5\">\n            {STORIES.map((story, index) => (\n              <button\n                type=\"button\"\n                key={story.title}\n                tabIndex={screen === \"home\" ? 0 : -1}\n                onClick={index === TARGET ? openDetail : undefined}\n                className={`flex w-full items-center gap-3.5 rounded-[20px] p-2.5 text-left ring-1 transition-colors ${\n                  index === TARGET\n                    ? \"bg-[#3a1f16] ring-[#e0673a]/40 hover:bg-[#43241a]\"\n                    : \"bg-white/[0.04] ring-white/[0.05]\"\n                }`}\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"size-[52px] shrink-0 rounded-[14px]\"\n                  style={{\n                    background: `radial-gradient(circle at 30% 30%, ${story.hue} 0%, #1a1311 85%)`,\n                  }}\n                />\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block text-[14px] font-semibold text-white\">{story.title}</span>\n                  <span className=\"mt-0.5 block truncate text-[12px] text-white/45\">\n                    {story.blurb}\n                  </span>\n                </span>\n                {index === TARGET && (\n                  <ChevronRight className=\"mr-1 size-4 shrink-0 text-white/40\" />\n                )}\n              </button>\n            ))}\n          </div>\n          <p className=\"absolute inset-x-0 bottom-9 text-center text-[11px] text-white/30\">\n            Open the highlighted story\n          </p>\n          <motion.div\n            aria-hidden=\"true\"\n            style={{ opacity: homeDim }}\n            className=\"pointer-events-none absolute inset-0 bg-black\"\n          />\n        </motion.div>\n\n        <motion.article\n          aria-hidden={screen === \"home\"}\n          style={{ scale: detailScale, x: offset, y: lift }}\n          className=\"absolute inset-0 overflow-hidden rounded-[50px] bg-[#1a1310] shadow-[0_24px_60px_rgb(0_0_0/0.7)]\"\n        >\n          <div className=\"relative h-[292px] overflow-hidden bg-[radial-gradient(circle_at_28%_38%,#fdba74_0%,#e0673a_28%,#44211a_70%)]\">\n            <div className=\"absolute -right-10 bottom-[-60px] size-[220px] rounded-full bg-[#2a120c]/50 blur-[2px]\" />\n            <div className=\"absolute top-[120px] left-[150px] size-[64px] rounded-full bg-white/15 ring-1 ring-white/25\" />\n            <div className=\"absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-[#1a1310] to-transparent\" />\n            <button\n              type=\"button\"\n              aria-label=\"Back\"\n              tabIndex={screen === \"detail\" ? 0 : -1}\n              onClick={() => settle(EXIT, 0)}\n              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\"\n            >\n              <ChevronLeft className=\"size-5\" />\n            </button>\n          </div>\n          <div className=\"relative -mt-6 px-6\">\n            <p className=\"text-[11px] font-semibold tracking-[0.16em] text-orange-300 uppercase\">\n              Navigation\n            </p>\n            <p className=\"mt-2 text-[24px] leading-[1.15] font-bold tracking-tight text-white\">\n              Edge peeks: predictive back and the end of mystery navigation\n            </p>\n            <div className=\"mt-4 flex items-center gap-2.5\">\n              <span className=\"size-7 rounded-full bg-[linear-gradient(135deg,#f472b6,#6366f1)]\" />\n              <p className=\"text-[12px] text-white/50\">Mara Okafor · 6 min read</p>\n            </div>\n            <p className=\"mt-4 text-[14px] leading-relaxed text-white/65\">\n              Android 14 made the back gesture honest. Pull from the left edge and the current\n              screen shrinks in your hand, revealing exactly where you&apos;ll land before you\n              commit.\n            </p>\n            <p className=\"mt-3 text-[14px] leading-relaxed text-white/65\">\n              Let go early and it springs back. Flick and it&apos;s gone. No leap of faith.\n            </p>\n          </div>\n          <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\">\n            <p className=\"text-[11px] text-white/30\">Drag from the left edge</p>\n          </div>\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute top-1/2 left-1.5 h-12 w-1 -translate-y-1/2 rounded-full bg-white/20\"\n          />\n        </motion.article>\n\n        <motion.div\n          aria-hidden=\"true\"\n          style={{\n            backgroundColor: arrowFill,\n            color: arrowInk,\n            height: ARROW_SIZE,\n            opacity: arrowOpacity,\n            scale: arrowScale,\n            width: ARROW_SIZE,\n            x: arrowX,\n            y: arrowTop,\n          }}\n          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\"\n        >\n          <ArrowLeft className=\"size-[18px]\" />\n        </motion.div>\n      </div>\n    </PhoneFrame>\n  );\n};\n","path":"/predictive-back.tsx","target":"uicapsule/predictive-back/predictive-back.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport type { CSSProperties } from \"react\";\n\nimport { PredictiveBack } from \"./predictive-back\";\n\n// Light pools on the left, the edge the back gesture pulls from.\nconst backdrop: CSSProperties = {\n  background: [\n    \"radial-gradient(ellipse 45% 80% at 0% 50%, rgb(176 82 48 / 0.4), transparent 70%)\",\n    \"radial-gradient(ellipse 50% 55% at 50% 45%, rgb(110 48 30 / 0.35), transparent 72%)\",\n    \"linear-gradient(100deg, #3a1a10 0%, #26110b 45%, #140806 100%)\",\n  ].join(\", \"),\n};\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden p-5\" style={backdrop}>\n    <PredictiveBack />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/predictive-back/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/predictive-back","name":"predictive-back","registryDependencies":[],"title":"Predictive Back","type":"registry:block"}