Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  AlertTriangle,
  CheckCircle2,
  Info,
  Mic,
  MicOff,
  Pause,
  Phone,
  PhoneOff,
  Play,
  SkipBack,
  SkipForward,
  Timer,
  Upload,
  X,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type IslandActivity =
  | { kind: "call"; id: string; name: string; subtitle?: string }
  | { kind: "timer"; id: string; label: string; /** seconds */ duration: number }
  | { kind: "music"; id: string; title: string; artist: string; /** seconds */ duration: number; colors?: [string, string] }
  | { kind: "progress"; id: string; label: string; /** 0–1 */ progress: number; detail?: string }
  | { kind: "toast"; id: string; message: string; tone?: "success" | "error" | "info"; /** ms */ duration?: number };

export type IslandAction = "accept" | "decline" | "end" | "mute" | "unmute" | "pause" | "resume" | "cancel" | "done" | "prev" | "next";

export interface NotificationIslandProps {
  activity: IslandActivity | null;
  expanded?: boolean;
  defaultExpanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
  onAction?: (action: IslandAction, activity: IslandActivity) => void;
  /** Called when a toast times out, a call ends or a timer is cancelled. */
  onDismiss?: (activity: IslandActivity) => void;
  /** "absolute" pins to the top of the nearest positioned parent; "fixed" to the viewport. */
  position?: "absolute" | "fixed" | "static";
  className?: string;
}

const SPRING = { type: "spring", stiffness: 380, damping: 30, mass: 0.9 } as const;

function fmt(s: number) {
  const v = Math.max(0, Math.round(s));
  return `${Math.floor(v / 60)}:${String(v % 60).padStart(2, "0")}`;
}
const initials = (name: string) =>
  name
    .split(/\s+/)
    .map((p) => p[0])
    .slice(0, 2)
    .join("")
    .toUpperCase();

function dims(a: IslandActivity | null, expanded: boolean) {
  if (!a) return { w: 120, h: 34, r: 17 };
  if (a.kind === "toast") return { w: 300, h: 42, r: 21 };
  if (!expanded) return { w: 240, h: 36, r: 18 };
  const h = a.kind === "music" ? 168 : 96;
  return { w: 360, h, r: a.kind === "music" ? 36 : 32 };
}

export function NotificationIsland({
  activity,
  expanded: expandedProp,
  defaultExpanded = false,
  onExpandedChange,
  onAction,
  onDismiss,
  position = "absolute",
  className,
}: NotificationIslandProps) {
  const reduce = useReducedMotion() ?? false;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [innerExp, setInnerExp] = React.useState(defaultExpanded);
  const expanded = (expandedProp ?? innerExp) && !!activity && activity.kind !== "toast";
  const setExpanded = React.useCallback(
    (v: boolean) => {
      if (expandedProp === undefined) setInnerExp(v);
      onExpandedChange?.(v);
    },
    [expandedProp, onExpandedChange],
  );
  const [maxW, setMaxW] = React.useState(360);
  const [now, setNow] = React.useState(0);
  const [visible, setVisible] = React.useState(true);

  // Per-activity runtime state.
  const [call, setCall] = React.useState<{ id: string; since: number | null; muted: boolean } | null>(null);
  const [timer, setTimer] = React.useState<{ id: string; endsAt: number; pausedLeft: number | null } | null>(null);
  const [music, setMusic] = React.useState<{ id: string; pos: number; playing: boolean; at: number } | null>(null);

  React.useEffect(() => {
    const el = rootRef.current?.parentElement;
    if (!el) return;
    const ro = new ResizeObserver(([e]) => setMaxW(Math.max(200, e.contentRect.width - 16)));
    ro.observe(el);
    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting));
    if (rootRef.current) io.observe(rootRef.current);
    return () => {
      ro.disconnect();
      io.disconnect();
    };
  }, []);

  // Initialise runtime state when a new activity arrives.
  React.useEffect(() => {
    const t = performance.now();
    /* eslint-disable react-hooks/set-state-in-effect -- runtime state is derived from the incoming activity */
    setNow(t);
    if (activity?.kind === "call") setCall((c) => (c?.id === activity.id ? c : { id: activity.id, since: null, muted: false }));
    if (activity?.kind === "timer") setTimer((c) => (c?.id === activity.id ? c : { id: activity.id, endsAt: t + activity.duration * 1000, pausedLeft: null }));
    if (activity?.kind === "music") setMusic((c) => (c?.id === activity.id ? c : { id: activity.id, pos: 0, playing: true, at: t }));
    /* eslint-enable react-hooks/set-state-in-effect */
  }, [activity]);

  // Clock.
  const needsClock =
    visible &&
    ((activity?.kind === "call" && call?.since != null) ||
      (activity?.kind === "timer" && timer?.pausedLeft == null) ||
      (activity?.kind === "music" && music?.playing));
  React.useEffect(() => {
    if (!needsClock) return;
    const id = setInterval(() => setNow(performance.now()), 250);
    return () => clearInterval(id);
  }, [needsClock]);

  // Toast auto-dismiss.
  const dismissRef = React.useRef(onDismiss);
  React.useEffect(() => {
    dismissRef.current = onDismiss;
  }, [onDismiss]);
  const toastId = activity?.kind === "toast" ? activity.id : null;
  const toastMs = activity?.kind === "toast" ? (activity.duration ?? 3200) : 0;
  React.useEffect(() => {
    if (!toastId || !activity) return;
    const a = activity;
    const id = setTimeout(() => dismissRef.current?.(a), toastMs);
    return () => clearTimeout(id);
    // Restart only when a different toast arrives.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [toastId, toastMs]);

  // Timer completion.
  const timerLeft = activity?.kind === "timer" && timer?.id === activity.id ? (timer.pausedLeft ?? Math.max(0, (timer.endsAt - now) / 1000)) : 0;
  const timerDone = activity?.kind === "timer" && timer?.id === activity.id && timer.pausedLeft == null && now > 0 && timerLeft <= 0;
  const doneFired = React.useRef<string | null>(null);
  React.useEffect(() => {
    if (timerDone && activity && doneFired.current !== activity.id) {
      doneFired.current = activity.id;
      onAction?.("done", activity);
    }
  }, [timerDone, activity, onAction]);

  // Collapse on outside click / Escape.
  React.useEffect(() => {
    if (!expanded) return;
    const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setExpanded(false);
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setExpanded(false);
    document.addEventListener("pointerdown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [expanded, setExpanded]);

  const act = (a: IslandAction) => {
    if (!activity) return;
    const t = performance.now();
    if (activity.kind === "call") {
      if (a === "accept") setCall((c) => c && { ...c, since: t });
      if (a === "mute" || a === "unmute") setCall((c) => c && { ...c, muted: a === "mute" });
      if (a === "decline" || a === "end") {
        setExpanded(false);
        onDismiss?.(activity);
      }
    }
    if (activity.kind === "timer") {
      if (a === "pause") setTimer((c) => c && { ...c, pausedLeft: Math.max(0, (c.endsAt - t) / 1000) });
      if (a === "resume") setTimer((c) => c && { ...c, endsAt: t + (c.pausedLeft ?? 0) * 1000, pausedLeft: null });
      if (a === "cancel") {
        setExpanded(false);
        onDismiss?.(activity);
      }
    }
    if (activity.kind === "music") {
      setMusic((m) => {
        if (!m) return m;
        const pos = m.playing ? m.pos + (t - m.at) / 1000 : m.pos;
        if (a === "pause") return { ...m, pos, playing: false, at: t };
        if (a === "resume") return { ...m, pos, playing: true, at: t };
        if (a === "prev") return { ...m, pos: 0, at: t };
        if (a === "next") return { ...m, pos: 0, at: t };
        return m;
      });
    }
    if (activity.kind === "progress" && a === "cancel") {
      setExpanded(false);
      onDismiss?.(activity);
    }
    onAction?.(a, activity);
    setNow(t);
  };

  const d = dims(activity, expanded);
  const w = Math.min(d.w, maxW);
  const contentKey = activity ? `${activity.kind}-${activity.id}-${expanded ? "x" : "c"}` : "idle";
  const summary = describe(activity, { timerLeft, call, music, now });

  return (
    <div
      ref={rootRef}
      className={cn(
        "z-40 flex justify-center",
        position === "absolute" && "absolute inset-x-0 top-3",
        position === "fixed" && "fixed inset-x-0 top-3",
        className,
      )}
    >
      <motion.div
        key={activity?.id ?? "idle"}
        initial={reduce ? false : { scale: activity ? 0.92 : 1 }}
        animate={{ scale: 1 }}
        transition={{ type: "spring", stiffness: 500, damping: 18 }}
      >
        <motion.div
          className="relative overflow-hidden bg-black text-white shadow-[0_10px_30px_-10px_rgba(0,0,0,0.6)] ring-1 ring-white/10"
          initial={false}
          animate={{ width: w, height: d.h, borderRadius: d.r }}
          transition={reduce ? { duration: 0 } : SPRING}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={contentKey}
              className="absolute inset-0"
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, filter: "blur(6px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, filter: "blur(6px)" }}
              transition={{ duration: 0.28, ease: [0.2, 0.8, 0.2, 1] }}
            >
              {activity && !expanded && activity.kind !== "toast" && (
                <button
                  type="button"
                  aria-expanded={false}
                  aria-label={`${summary}. Expand`}
                  onClick={() => setExpanded(true)}
                  className="absolute inset-0 z-10 rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-inset"
                />
              )}
              {activity && (
                <Content
                  activity={activity}
                  expanded={expanded}
                  timerLeft={timerLeft}
                  timerDone={timerDone}
                  timerPaused={timer?.pausedLeft != null}
                  call={call}
                  music={music}
                  now={now}
                  act={act}
                  reduce={reduce}
                  onCollapse={() => setExpanded(false)}
                />
              )}
            </motion.div>
          </AnimatePresence>
        </motion.div>
      </motion.div>
      <p className="sr-only" aria-live="polite">
        {summary}
      </p>
    </div>
  );
}

function describe(
  a: IslandActivity | null,
  s: { timerLeft: number; call: { since: number | null } | null; music: unknown; now: number },
): string {
  if (!a) return "";
  switch (a.kind) {
    case "call":
      return s.call?.since != null ? `On call with ${a.name}` : `Incoming call from ${a.name}`;
    case "timer":
      return `${a.label}: ${fmt(s.timerLeft)} left`;
    case "music":
      return `Now playing ${a.title} by ${a.artist}`;
    case "progress":
      return `${a.label} ${Math.round(a.progress * 100)}%`;
    case "toast":
      return a.message;
  }
}

interface ContentProps {
  activity: IslandActivity;
  expanded: boolean;
  timerLeft: number;
  timerDone: boolean;
  timerPaused: boolean;
  call: { id: string; since: number | null; muted: boolean } | null;
  music: { id: string; pos: number; playing: boolean; at: number } | null;
  now: number;
  act: (a: IslandAction) => void;
  reduce: boolean;
  onCollapse: () => void;
}

function Content({ activity: a, expanded, timerLeft, timerDone, timerPaused, call, music, now, act, reduce, onCollapse }: ContentProps) {
  const compact = "flex h-full items-center justify-between gap-3 pr-3 pl-2";
  if (a.kind === "toast") {
    const tone = a.tone ?? "success";
    const Icon = tone === "success" ? CheckCircle2 : tone === "error" ? AlertTriangle : Info;
    return (
      <div className="flex h-full items-center gap-2.5 px-4 text-sm font-medium" role="status">
        <Icon className={cn("size-4 shrink-0", tone === "success" ? "text-emerald-400" : tone === "error" ? "text-rose-400" : "text-sky-400")} />
        <span className="truncate">{a.message}</span>
      </div>
    );
  }

  if (a.kind === "call") {
    const live = call?.id === a.id && call.since != null;
    const secs = live ? (now - (call.since ?? now)) / 1000 : 0;
    if (!expanded)
      return (
        <div className={compact}>
          <Avatar name={a.name} size={24} />
          <span className="min-w-0 flex-1 truncate text-xs font-medium">{live ? fmt(secs) : a.name}</span>
          {live ? <Bars color="bg-emerald-400" reduce={reduce} /> : <RingingPhone reduce={reduce} />}
        </div>
      );
    return (
      <div className="flex h-full items-center gap-3 px-4">
        <Avatar name={a.name} size={48} />
        <div className="min-w-0 flex-1">
          <p className="text-[11px] text-white/55">{live ? `On call · ${fmt(secs)}` : (a.subtitle ?? "Incoming call")}</p>
          <p className="truncate text-base font-semibold">{a.name}</p>
        </div>
        {live ? (
          <>
            <RoundButton label={call?.muted ? "Unmute" : "Mute"} onClick={() => act(call?.muted ? "unmute" : "mute")} className="bg-white/15 hover:bg-white/25">
              {call?.muted ? <MicOff className="size-4" /> : <Mic className="size-4" />}
            </RoundButton>
            <RoundButton label="End call" onClick={() => act("end")} className="bg-rose-500 hover:bg-rose-400">
              <PhoneOff className="size-4" />
            </RoundButton>
          </>
        ) : (
          <>
            <RoundButton label="Decline" onClick={() => act("decline")} className="bg-rose-500 hover:bg-rose-400">
              <PhoneOff className="size-4" />
            </RoundButton>
            <RoundButton label="Accept" onClick={() => act("accept")} className="bg-emerald-500 hover:bg-emerald-400">
              <Phone className="size-4" />
            </RoundButton>
          </>
        )}
      </div>
    );
  }

  if (a.kind === "timer") {
    const pct = a.duration ? 1 - timerLeft / a.duration : 1;
    if (!expanded)
      return (
        <div className={compact}>
          <span className="flex items-center gap-1.5 pl-1 text-xs font-medium text-amber-400">
            <Timer className="size-4" /> {timerDone ? "Done" : a.label}
          </span>
          <span className="text-sm font-semibold text-amber-400 tabular-nums">{fmt(timerLeft)}</span>
        </div>
      );
    return (
      <div className="flex h-full items-center gap-3 px-4">
        <Ring pct={pct} size={48} color="#fbbf24">
          <Timer className="size-4 text-amber-400" />
        </Ring>
        <div className="min-w-0 flex-1">
          <p className="truncate text-[11px] text-white/55">{timerDone ? "Time’s up" : a.label}</p>
          <p className="text-2xl leading-none font-semibold tracking-tight text-amber-400 tabular-nums">{fmt(timerLeft)}</p>
        </div>
        {!timerDone && (
          <RoundButton label={timerPaused ? "Resume timer" : "Pause timer"} onClick={() => act(timerPaused ? "resume" : "pause")} className="bg-amber-400/20 text-amber-300 hover:bg-amber-400/30">
            {timerPaused ? <Play className="size-4 fill-current" /> : <Pause className="size-4 fill-current" />}
          </RoundButton>
        )}
        <RoundButton label={timerDone ? "Dismiss" : "Cancel timer"} onClick={() => act("cancel")} className="bg-white/15 hover:bg-white/25">
          <X className="size-4" />
        </RoundButton>
      </div>
    );
  }

  if (a.kind === "music") {
    const m = music?.id === a.id ? music : null;
    const pos = m ? Math.min(a.duration, m.playing ? m.pos + (now - m.at) / 1000 : m.pos) : 0;
    const playing = !!m?.playing && pos < a.duration;
    const [c1, c2] = a.colors ?? ["#f472b6", "#7c3aed"];
    if (!expanded)
      return (
        <div className={compact}>
          <span className="size-6 rounded-md" style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }} />
          <span className="min-w-0 flex-1 truncate text-xs font-medium">{a.title}</span>
          <Bars color="bg-pink-400" reduce={reduce || !playing} />
        </div>
      );
    return (
      <div className="flex h-full flex-col justify-center gap-3 px-5">
        <div className="flex items-center gap-3">
          <span className="relative size-14 shrink-0 overflow-hidden rounded-xl" style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }}>
            <span className="absolute inset-3 rounded-full border border-white/40" />
            <span className="absolute inset-[22px] rounded-full bg-white/70" />
          </span>
          <div className="min-w-0 flex-1">
            <p className="truncate font-semibold">{a.title}</p>
            <p className="truncate text-sm text-white/60">{a.artist}</p>
          </div>
          <Bars color="bg-pink-400" reduce={reduce || !playing} />
        </div>
        <div className="flex items-center gap-2 font-mono text-[10px] text-white/55 tabular-nums">
          <span>{fmt(pos)}</span>
          <span className="h-1 flex-1 overflow-hidden rounded-full bg-white/20">
            <span className="block h-full rounded-full bg-white transition-[width] duration-300 ease-linear" style={{ width: `${(pos / a.duration) * 100}%` }} />
          </span>
          <span>-{fmt(a.duration - pos)}</span>
        </div>
        <div className="flex items-center justify-center gap-5">
          <RoundButton label="Previous track" onClick={() => act("prev")} className="bg-transparent hover:bg-white/10">
            <SkipBack className="size-5 fill-current" />
          </RoundButton>
          <RoundButton label={playing ? "Pause" : "Play"} onClick={() => act(playing ? "pause" : "resume")} className="size-11 bg-white text-black hover:bg-white/90">
            {playing ? <Pause className="size-5 fill-current" /> : <Play className="size-5 translate-x-px fill-current" />}
          </RoundButton>
          <RoundButton label="Next track" onClick={() => act("next")} className="bg-transparent hover:bg-white/10">
            <SkipForward className="size-5 fill-current" />
          </RoundButton>
        </div>
      </div>
    );
  }

  // progress
  const pct = Math.max(0, Math.min(1, a.progress));
  const done = pct >= 1;
  if (!expanded)
    return (
      <div className={compact}>
        <span className="flex min-w-0 items-center gap-1.5 pl-1 text-xs font-medium">
          {done ? <CheckCircle2 className="size-4 shrink-0 text-emerald-400" /> : <Upload className="size-4 shrink-0 text-sky-400" />}
          <span className="truncate">{done ? "Uploaded" : a.label}</span>
        </span>
        <Ring pct={pct} size={22} color={done ? "#34d399" : "#38bdf8"} />
      </div>
    );
  return (
    <div className="flex h-full items-center gap-3 px-4">
      <Ring pct={pct} size={48} color={done ? "#34d399" : "#38bdf8"}>
        <span className="font-mono text-[11px] font-semibold tabular-nums">{Math.round(pct * 100)}%</span>
      </Ring>
      <div className="min-w-0 flex-1">
        <p className="truncate text-sm font-semibold">{done ? "Upload complete" : a.label}</p>
        <p className="truncate text-[11px] text-white/55">{a.detail ?? (done ? "All files synced" : "Uploading…")}</p>
        <span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/15">
          <span className={cn("block h-full rounded-full transition-[width] duration-300", done ? "bg-emerald-400" : "bg-sky-400")} style={{ width: `${pct * 100}%` }} />
        </span>
      </div>
      <RoundButton label={done ? "Close" : "Cancel upload"} onClick={() => (done ? onCollapse() : act("cancel"))} className="bg-white/15 hover:bg-white/25">
        <X className="size-4" />
      </RoundButton>
    </div>
  );
}

function Avatar({ name, size }: { name: string; size: number }) {
  const hue = [...name].reduce((s, c) => s + c.charCodeAt(0), 0) % 360;
  return (
    <span
      aria-hidden
      className="grid shrink-0 place-items-center rounded-full font-semibold text-white"
      style={{ width: size, height: size, fontSize: size * 0.38, background: `linear-gradient(135deg, oklch(0.7 0.15 ${hue}), oklch(0.5 0.18 ${hue + 50}))` }}
    >
      {initials(name)}
    </span>
  );
}

function RoundButton({ children, label, onClick, className }: { children: React.ReactNode; label: string; onClick: () => void; className?: string }) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      className={cn("relative z-20 grid size-10 shrink-0 place-items-center rounded-full text-white transition outline-none focus-visible:ring-2 focus-visible:ring-white/80", className)}
    >
      {children}
    </button>
  );
}

function Bars({ color, reduce }: { color: string; reduce: boolean }) {
  return (
    <span className="flex h-4 items-center gap-[2px]" aria-hidden>
      {[0, 1, 2, 3].map((i) => (
        <motion.span
          key={i}
          className={cn("w-[3px] rounded-full", color)}
          animate={reduce ? { height: 5 } : { height: [5, 15, 7, 12, 5] }}
          transition={reduce ? { duration: 0.2 } : { duration: 0.8 + i * 0.1, repeat: Infinity, delay: i * 0.1, ease: "easeInOut" }}
        />
      ))}
    </span>
  );
}

function RingingPhone({ reduce }: { reduce: boolean }) {
  return (
    <motion.span
      className="grid size-6 place-items-center rounded-full bg-emerald-500"
      animate={reduce ? undefined : { rotate: [0, -14, 14, -10, 10, 0] }}
      transition={{ duration: 0.9, repeat: Infinity, repeatDelay: 0.6 }}
      aria-hidden
    >
      <Phone className="size-3.5 fill-current" />
    </motion.span>
  );
}

function Ring({ pct, size, color, children }: { pct: number; size: number; color: string; children?: React.ReactNode }) {
  const stroke = size > 30 ? 4 : 3;
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  return (
    <span className="relative grid shrink-0 place-items-center" style={{ width: size, height: size }}>
      <svg width={size} height={size} className="absolute inset-0 -rotate-90" aria-hidden>
        <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth={stroke} />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          stroke={color}
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={c}
          strokeDashoffset={c * (1 - Math.max(0, Math.min(1, pct)))}
          style={{ transition: "stroke-dashoffset 0.3s linear" }}
        />
      </svg>
      {children}
    </span>
  );
}

More in Overlays & Feedback

View all →