# AirDrop Radar

> AirDrop's ripple radar — nearby people surface on expanding rings; tap one and a progress ring wraps their avatar.

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

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

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/airdrop-radar.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

### `/airdrop-radar.tsx`

```tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { Check, Wifi } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { PhoneFrame } from "./phone-frame";

interface Person {
  id: string;
  name: string;
  initials: string;
  gradient: string;
  x: number;
  y: number;
}

const PEOPLE: Person[] = [
  {
    gradient: "from-[#60a5fa] to-[#2563eb]",
    id: "riley",
    initials: "RC",
    name: "Riley",
    x: -72,
    y: -372,
  },
  {
    gradient: "from-[#f0a58f] to-[#c76b52]",
    id: "nova",
    initials: "NV",
    name: "Nova",
    x: 100,
    y: -290,
  },
  {
    gradient: "from-[#a78bfa] to-[#7c3aed]",
    id: "mia",
    initials: "MT",
    name: "Mia",
    x: 108,
    y: -118,
  },
  {
    gradient: "from-[#34d399] to-[#059669]",
    id: "sam",
    initials: "SO",
    name: "Sam",
    x: -96,
    y: -186,
  },
];

type SendPhase = "sending" | "sent";
type SendState = { id: string; phase: SendPhase } | null;

const PHASE_LABEL: Record<SendPhase, string> = { sending: "Sending…", sent: "Sent" };
const PHASE_STATUS: Record<SendPhase, string> = { sending: "Sending 3 photos…", sent: "Delivered" };

const RING_CIRCUMFERENCE = 2 * Math.PI * 30;

export const AirdropRadar = () => {
  const [send, setSend] = useState<SendState>(null);
  const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
  const reduceMotion = useReducedMotion();

  useEffect(() => {
    const timers = timersRef.current;
    return () => {
      for (const timer of timers) {
        clearTimeout(timer);
      }
    };
  }, []);

  const share = useCallback((id: string) => {
    setSend({ id, phase: "sending" });
    timersRef.current.push(
      setTimeout(() => {
        setSend({ id, phase: "sent" });
        timersRef.current.push(setTimeout(() => setSend(null), 2200));
      }, 1900),
    );
  }, []);

  return (
    <PhoneFrame tone="slate" className="bg-[#08101d]">
      <div className="absolute bottom-28 left-1/2 -translate-x-1/2">
        {[0, 1, 2].map((ring) => (
          <motion.span
            key={ring}
            aria-hidden
            className="absolute top-1/2 left-1/2 rounded-full border border-sky-400/40"
            style={{ height: 80, width: 80, x: "-50%", y: "-50%" }}
            animate={
              reduceMotion
                ? { opacity: 0.4 - ring * 0.1, scale: 2.4 + ring * 2.4 }
                : { opacity: [0.55, 0], scale: [1, 9] }
            }
            transition={
              reduceMotion
                ? { duration: 0 }
                : { delay: ring * 1.2, duration: 3.6, ease: "easeOut", repeat: Infinity }
            }
          />
        ))}
        {PEOPLE.map((person, index) => {
          const state = send?.id === person.id ? send.phase : null;
          const dimmed = send !== null && send.id !== person.id;
          return (
            <motion.button
              type="button"
              key={person.id}
              onClick={() => share(person.id)}
              animate={{ opacity: dimmed ? 0.35 : 1, scale: dimmed ? 0.92 : 1 }}
              transition={{ bounce: 0.3, duration: 0.45, type: "spring" }}
              className="absolute -translate-x-1/2 -translate-y-1/2"
              style={{ left: 40 + person.x, top: 40 + person.y }}
              disabled={send !== null}
            >
              <motion.span
                initial={{ opacity: 0, scale: 0.5 }}
                animate={{ opacity: 1, scale: 1 }}
                transition={{
                  bounce: 0.4,
                  delay: 0.3 + index * 0.18,
                  duration: 0.5,
                  type: "spring",
                }}
                className="flex flex-col items-center gap-1.5"
              >
                <span className="relative grid size-[60px] place-items-center">
                  <svg viewBox="0 0 68 68" className="absolute inset-0 size-[60px] -rotate-90">
                    <AnimatePresence>
                      {state && (
                        <motion.circle
                          cx="34"
                          cy="34"
                          r="30"
                          fill="none"
                          stroke={state === "sent" ? "#30d158" : "#0a84ff"}
                          strokeWidth="3.5"
                          strokeLinecap="round"
                          strokeDasharray={RING_CIRCUMFERENCE}
                          initial={{ strokeDashoffset: RING_CIRCUMFERENCE }}
                          animate={{
                            strokeDashoffset: state === "sent" ? 0 : RING_CIRCUMFERENCE * 0.02,
                          }}
                          exit={{ opacity: 0 }}
                          transition={{ duration: state === "sent" ? 0.2 : 1.9, ease: "easeInOut" }}
                        />
                      )}
                    </AnimatePresence>
                  </svg>
                  <span
                    className={`grid size-[52px] place-items-center rounded-full bg-gradient-to-b text-[15px] font-semibold text-white ${person.gradient}`}
                  >
                    {state === "sent" ? (
                      <motion.span
                        initial={{ scale: 0 }}
                        animate={{ scale: 1 }}
                        transition={{ bounce: 0.55, duration: 0.35, type: "spring" }}
                        className="grid size-full place-items-center rounded-full bg-[#30d158]"
                      >
                        <Check className="size-6 text-white" strokeWidth={3} />
                      </motion.span>
                    ) : (
                      person.initials
                    )}
                  </span>
                </span>
                <span className="text-[11px] font-medium text-white/70">
                  {state ? PHASE_LABEL[state] : person.name}
                </span>
              </motion.span>
            </motion.button>
          );
        })}
        <div className="relative grid size-20 place-items-center rounded-full bg-white/[0.06] ring-1 ring-white/15 backdrop-blur-xl">
          <div className="grid size-14 place-items-center rounded-full bg-gradient-to-b from-[#8b93a3] to-[#5b6472] text-[16px] font-semibold text-white">
            KL
          </div>
        </div>
      </div>
      <div className="absolute inset-x-0 top-0 flex items-center justify-between px-6 pt-[58px]">
        <div>
          <p className="text-[20px] font-bold text-white">AirDrop</p>
          <p className="mt-0.5 text-[12px] text-white/45">
            {send ? PHASE_STATUS[send.phase] : "4 people nearby"}
          </p>
        </div>
        <span className="grid size-9 place-items-center rounded-full bg-white/[0.08] text-sky-400">
          <Wifi className="size-4" />
        </span>
      </div>

      <p className="pointer-events-none absolute inset-x-0 bottom-8 text-center text-[11px] text-white/30">
        Tap someone to send
      </p>
    </PhoneFrame>
  );
};
```

### `/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>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { AirdropRadar } from "./airdrop-radar";

const Preview = () => (
  <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">
    <AirdropRadar />
  </main>
);

export default Preview;
```
