{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["effects","minimal"],"dependencies":["lucide-react","motion"],"description":"AirDrop's ripple radar — nearby people surface on expanding rings; tap one and a progress ring wraps their avatar.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Check, Wifi } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { PhoneFrame } from \"./phone-frame\";\n\ninterface Person {\n  id: string;\n  name: string;\n  initials: string;\n  gradient: string;\n  x: number;\n  y: number;\n}\n\nconst PEOPLE: Person[] = [\n  {\n    gradient: \"from-[#60a5fa] to-[#2563eb]\",\n    id: \"riley\",\n    initials: \"RC\",\n    name: \"Riley\",\n    x: -72,\n    y: -372,\n  },\n  {\n    gradient: \"from-[#f0a58f] to-[#c76b52]\",\n    id: \"nova\",\n    initials: \"NV\",\n    name: \"Nova\",\n    x: 100,\n    y: -290,\n  },\n  {\n    gradient: \"from-[#a78bfa] to-[#7c3aed]\",\n    id: \"mia\",\n    initials: \"MT\",\n    name: \"Mia\",\n    x: 108,\n    y: -118,\n  },\n  {\n    gradient: \"from-[#34d399] to-[#059669]\",\n    id: \"sam\",\n    initials: \"SO\",\n    name: \"Sam\",\n    x: -96,\n    y: -186,\n  },\n];\n\ntype SendPhase = \"sending\" | \"sent\";\ntype SendState = { id: string; phase: SendPhase } | null;\n\nconst PHASE_LABEL: Record<SendPhase, string> = { sending: \"Sending…\", sent: \"Sent\" };\nconst PHASE_STATUS: Record<SendPhase, string> = { sending: \"Sending 3 photos…\", sent: \"Delivered\" };\n\nconst RING_CIRCUMFERENCE = 2 * Math.PI * 30;\n\nexport const AirdropRadar = () => {\n  const [send, setSend] = useState<SendState>(null);\n  const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const reduceMotion = useReducedMotion();\n\n  useEffect(() => {\n    const timers = timersRef.current;\n    return () => {\n      for (const timer of timers) {\n        clearTimeout(timer);\n      }\n    };\n  }, []);\n\n  const share = useCallback((id: string) => {\n    setSend({ id, phase: \"sending\" });\n    timersRef.current.push(\n      setTimeout(() => {\n        setSend({ id, phase: \"sent\" });\n        timersRef.current.push(setTimeout(() => setSend(null), 2200));\n      }, 1900),\n    );\n  }, []);\n\n  return (\n    <PhoneFrame tone=\"slate\" className=\"bg-[#08101d]\">\n      <div className=\"absolute bottom-28 left-1/2 -translate-x-1/2\">\n        {[0, 1, 2].map((ring) => (\n          <motion.span\n            key={ring}\n            aria-hidden\n            className=\"absolute top-1/2 left-1/2 rounded-full border border-sky-400/40\"\n            style={{ height: 80, width: 80, x: \"-50%\", y: \"-50%\" }}\n            animate={\n              reduceMotion\n                ? { opacity: 0.4 - ring * 0.1, scale: 2.4 + ring * 2.4 }\n                : { opacity: [0.55, 0], scale: [1, 9] }\n            }\n            transition={\n              reduceMotion\n                ? { duration: 0 }\n                : { delay: ring * 1.2, duration: 3.6, ease: \"easeOut\", repeat: Infinity }\n            }\n          />\n        ))}\n        {PEOPLE.map((person, index) => {\n          const state = send?.id === person.id ? send.phase : null;\n          const dimmed = send !== null && send.id !== person.id;\n          return (\n            <motion.button\n              type=\"button\"\n              key={person.id}\n              onClick={() => share(person.id)}\n              animate={{ opacity: dimmed ? 0.35 : 1, scale: dimmed ? 0.92 : 1 }}\n              transition={{ bounce: 0.3, duration: 0.45, type: \"spring\" }}\n              className=\"absolute -translate-x-1/2 -translate-y-1/2\"\n              style={{ left: 40 + person.x, top: 40 + person.y }}\n              disabled={send !== null}\n            >\n              <motion.span\n                initial={{ opacity: 0, scale: 0.5 }}\n                animate={{ opacity: 1, scale: 1 }}\n                transition={{\n                  bounce: 0.4,\n                  delay: 0.3 + index * 0.18,\n                  duration: 0.5,\n                  type: \"spring\",\n                }}\n                className=\"flex flex-col items-center gap-1.5\"\n              >\n                <span className=\"relative grid size-[60px] place-items-center\">\n                  <svg viewBox=\"0 0 68 68\" className=\"absolute inset-0 size-[60px] -rotate-90\">\n                    <AnimatePresence>\n                      {state && (\n                        <motion.circle\n                          cx=\"34\"\n                          cy=\"34\"\n                          r=\"30\"\n                          fill=\"none\"\n                          stroke={state === \"sent\" ? \"#30d158\" : \"#0a84ff\"}\n                          strokeWidth=\"3.5\"\n                          strokeLinecap=\"round\"\n                          strokeDasharray={RING_CIRCUMFERENCE}\n                          initial={{ strokeDashoffset: RING_CIRCUMFERENCE }}\n                          animate={{\n                            strokeDashoffset: state === \"sent\" ? 0 : RING_CIRCUMFERENCE * 0.02,\n                          }}\n                          exit={{ opacity: 0 }}\n                          transition={{ duration: state === \"sent\" ? 0.2 : 1.9, ease: \"easeInOut\" }}\n                        />\n                      )}\n                    </AnimatePresence>\n                  </svg>\n                  <span\n                    className={`grid size-[52px] place-items-center rounded-full bg-gradient-to-b text-[15px] font-semibold text-white ${person.gradient}`}\n                  >\n                    {state === \"sent\" ? (\n                      <motion.span\n                        initial={{ scale: 0 }}\n                        animate={{ scale: 1 }}\n                        transition={{ bounce: 0.55, duration: 0.35, type: \"spring\" }}\n                        className=\"grid size-full place-items-center rounded-full bg-[#30d158]\"\n                      >\n                        <Check className=\"size-6 text-white\" strokeWidth={3} />\n                      </motion.span>\n                    ) : (\n                      person.initials\n                    )}\n                  </span>\n                </span>\n                <span className=\"text-[11px] font-medium text-white/70\">\n                  {state ? PHASE_LABEL[state] : person.name}\n                </span>\n              </motion.span>\n            </motion.button>\n          );\n        })}\n        <div className=\"relative grid size-20 place-items-center rounded-full bg-white/[0.06] ring-1 ring-white/15 backdrop-blur-xl\">\n          <div className=\"grid size-14 place-items-center rounded-full bg-gradient-to-b from-[#8b93a3] to-[#5b6472] text-[16px] font-semibold text-white\">\n            KL\n          </div>\n        </div>\n      </div>\n      <div className=\"absolute inset-x-0 top-0 flex items-center justify-between px-6 pt-[58px]\">\n        <div>\n          <p className=\"text-[20px] font-bold text-white\">AirDrop</p>\n          <p className=\"mt-0.5 text-[12px] text-white/45\">\n            {send ? PHASE_STATUS[send.phase] : \"4 people nearby\"}\n          </p>\n        </div>\n        <span className=\"grid size-9 place-items-center rounded-full bg-white/[0.08] text-sky-400\">\n          <Wifi className=\"size-4\" />\n        </span>\n      </div>\n\n      <p className=\"pointer-events-none absolute inset-x-0 bottom-8 text-center text-[11px] text-white/30\">\n        Tap someone to send\n      </p>\n    </PhoneFrame>\n  );\n};\n","path":"/airdrop-radar.tsx","target":"uicapsule/airdrop-radar/airdrop-radar.tsx","type":"registry:file"},{"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/airdrop-radar/phone-frame.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { AirdropRadar } from \"./airdrop-radar\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden bg-[repeating-radial-gradient(circle_at_50%_50%,transparent_0_139px,rgb(103_232_249/0.045)_139px_140px),radial-gradient(circle_at_50%_50%,rgb(34_211_238/0.16)_0%,transparent_38%),radial-gradient(ellipse_at_50%_40%,#0f1c38_0%,#081226_55%,#030712_100%)] p-5\">\n    <AirdropRadar />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/airdrop-radar/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/airdrop-radar","name":"airdrop-radar","registryDependencies":[],"title":"AirDrop Radar","type":"registry:block"}