Skip to content

Dynamic Island

My attempt at recreating dynamic island from the animations.dev course.

Uses
motion

Code

import { useEffect, useState } from "react";
import {
  AnimatePresence,
  MotionConfig,
  motion,
  useReducedMotion,
  type Variants,
} from "motion/react";
import { cn } from "@manikrana/ui/lib/utils";
import { CloseSmallIcon, PauseIcon, PlayIcon } from "@/components/icons";

export type IslandView = "idle" | "ring" | "timer";

type ExitTarget = {
  scale?: number;
  scaleX?: number;
  y?: number;
};

const EXIT_TARGETS: Record<string, ExitTarget> = {
  "idle-ring": { scale: 0.9, scaleX: 0.9 },
  "ring-idle": { scale: 0.9, scaleX: 0.9 },
  "ring-timer": { scale: 1.4, y: 7.5 },
  "timer-idle": { scale: 0.7, y: -7.5 },
  "timer-ring": { scale: 0.7, y: -7.5 },
};

const BOUNCES: Record<string, number> = {
  idle: 0.5,
  "idle-ring": 0.5,
  "idle-timer": 0.3,
  "ring-idle": 0.5,
  "ring-timer": 0.35,
  "timer-idle": 0.3,
  "timer-ring": 0.35,
};

const exitVariants: Variants = {
  exit: (target: ExitTarget = {}) => ({
    ...target,
    opacity: [1, 0],
    filter: "blur(5px)",
  }),
};

export function DynamicIsland({
  view,
  onViewChange,
}: {
  view: IslandView;
  onViewChange?: (view: IslandView) => void;
}) {
  const reduceMotion = useReducedMotion();
  const [shownView, setShownView] = useState(view);
  const [change, setChange] = useState<string>(view);

  if (view !== shownView) {
    setShownView(view);
    setChange(`${shownView}-${view}`);
  }

  const bounce = reduceMotion ? 0 : (BOUNCES[change] ?? 0.35);
  const exitTarget = reduceMotion ? {} : (EXIT_TARGETS[change] ?? {});
  const transition = reduceMotion ? { duration: 0.01 } : { type: "spring" as const, bounce };
  const content = <IslandContent view={view} onTimerExit={() => onViewChange?.("idle")} />;

  return (
    <MotionConfig reducedMotion="user">
      <div className="relative flex justify-center">
        {/* borderRadius stays in `style` so layout animations correct its distortion. */}
        <motion.div
          layout
          className="min-w-25 shrink-0 overflow-hidden bg-black"
          style={{ borderRadius: 32 }}
          transition={transition}
          whileTap={view === "idle" && !reduceMotion ? { scale: 0.9 } : undefined}
        >
          <motion.div
            animate={{
              scale: 1,
              opacity: 1,
              filter: "blur(0px)",
              transition: { delay: reduceMotion ? 0 : 0.05 },
            }}
            initial={
              reduceMotion ? { opacity: 0 } : { scale: 0.9, opacity: 0, filter: "blur(5px)" }
            }
            key={view}
          >
            {content}
          </motion.div>
        </motion.div>

        <div className="pointer-events-none absolute top-0 left-1/2 flex w-80 -translate-x-1/2 items-start justify-center">
          <AnimatePresence custom={exitTarget} mode="popLayout">
            <motion.div exit="exit" initial={{ opacity: 0 }} key={view} variants={exitVariants}>
              {content}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </MotionConfig>
  );
}

function IslandContent({ view, onTimerExit }: { view: IslandView; onTimerExit: () => void }) {
  switch (view) {
    case "ring":
      return <Ring />;
    case "timer":
      return <Timer onExit={onTimerExit} />;
    case "idle":
      return <div className="h-7 w-25" />;
  }
}

function Ring() {
  const [isSilent, setIsSilent] = useState(false);
  const [firstTime, setFirstTime] = useState(true);
  const reduceMotion = useReducedMotion();

  useEffect(() => {
    const id = window.setTimeout(
      () => {
        setFirstTime(false);
        setIsSilent((silent) => !silent);
      },
      firstTime ? 1000 : 2000,
    );

    return () => window.clearTimeout(id);
  }, [firstTime, isSilent]);

  return (
    <motion.div
      animate={{ width: isSilent ? 148 : 128 }}
      className="relative flex h-7 items-center justify-between px-2.5"
      transition={{
        type: "spring",
        bounce: reduceMotion ? 0 : 0.5,
      }}
    >
      <motion.div
        animate={{
          width: isSilent ? 40 : 0,
          opacity: isSilent ? 1 : 0,
          filter: isSilent ? "blur(0px)" : "blur(2px)",
        }}
        className="absolute left-1.25 h-4.5 rounded-full bg-[#FD4F30]"
        initial={false}
        transition={{
          type: "spring",
          bounce: reduceMotion ? 0 : 0.35,
        }}
      />

      <motion.div
        animate={{
          rotate: reduceMotion
            ? 0
            : isSilent
              ? [0, -15, 5, -2, 0]
              : [0, 20, -15, 12.5, -10, 10, -7.5, 7.5, -5, 5, 0],
          x: isSilent ? 9 : 0,
        }}
        className="relative"
        initial={false}
      >
        <BellSvg />
        <motion.div
          animate={{
            opacity: isSilent ? 1 : 0,
          }}
          className="absolute inset-0 h-5 translate-x-1.25 -translate-y-1.25 -rotate-40 overflow-hidden"
          initial={false}
          transition={{ duration: reduceMotion ? 0.01 : 0.12 }}
        >
          <motion.div
            animate={{ height: isSilent ? 16 : 0 }}
            className="flex w-0.75 items-center justify-center rounded-full bg-[#FD4F30]"
            initial={false}
            transition={{
              duration: reduceMotion ? 0.01 : 0.16,
              delay: isSilent && !reduceMotion ? 0.15 : 0,
              ease: "easeInOut",
            }}
          >
            <div className="h-full w-px rounded-full bg-white" />
          </motion.div>
        </motion.div>
      </motion.div>

      <div className="ml-auto flex items-center">
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            animate={{
              opacity: 1,
              scale: 1,
              filter: "blur(0px)",
            }}
            className={cn("text-xs font-medium", isSilent ? "text-[#FD4F30]" : "text-white")}
            exit={{
              opacity: 0,
              scale: 0.92,
              filter: "blur(3px)",
              transformOrigin: isSilent ? "left center" : "right center",
            }}
            initial={{
              opacity: 0,
              scale: 0.92,
              filter: "blur(3px)",
              transformOrigin: isSilent ? "right center" : "left center",
            }}
            key={isSilent ? "silent" : "ring"}
            transition={{ duration: reduceMotion ? 0.01 : 0.14 }}
          >
            {isSilent ? "Silent" : "Ring"}
          </motion.span>
        </AnimatePresence>
      </div>
    </motion.div>
  );
}

function Timer({ onExit }: { onExit: () => void }) {
  const [isPaused, setIsPaused] = useState(false);
  const reduceMotion = useReducedMotion();
  const press = reduceMotion ? undefined : { scale: 0.9 };

  return (
    <div className="flex w-71 items-center gap-2 p-3 pr-5">
      <motion.button
        aria-label={isPaused ? "Play timer" : "Pause timer"}
        className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#5A3C07] text-[#FDB000] outline-none transition-colors hover:bg-[#694608] focus-visible:ring-3 focus-visible:ring-ring/50"
        onClick={() => setIsPaused((paused) => !paused)}
        type="button"
        whileTap={press}
      >
        <AnimatePresence initial={false} mode="wait">
          <motion.span
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            className="flex"
            exit={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
            initial={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
            key={isPaused ? "play" : "pause"}
            transition={{ duration: reduceMotion ? 0.01 : 0.1 }}
          >
            {isPaused ? <PlayIcon /> : <PauseIcon />}
          </motion.span>
        </AnimatePresence>
      </motion.button>

      <motion.button
        aria-label="Exit"
        className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#3C3D3C] text-white outline-none transition-colors hover:bg-[#4A4B4A] focus-visible:ring-3 focus-visible:ring-ring/50"
        onClick={onExit}
        type="button"
        whileTap={press}
      >
        <CloseSmallIcon className="size-6" />
      </motion.button>

      <div className="ml-auto flex shrink-0 items-baseline gap-1.5 pr-0.5 whitespace-nowrap text-[#F7A815]">
        <span className="text-sm leading-none font-medium">Timer</span>
        <Counter paused={isPaused} />
      </div>
    </div>
  );
}

function Counter({ paused }: { paused: boolean }) {
  const [count, setCount] = useState(60);

  useEffect(() => {
    if (paused) return;

    const id = window.setInterval(() => {
      setCount((current) => (current === 0 ? 60 : current - 1));
    }, 1000);

    return () => window.clearInterval(id);
  }, [paused]);

  const digits = count.toString().padStart(2, "0").split("");

  return (
    <div className="relative w-16 shrink-0 overflow-hidden text-3xl font-light whitespace-nowrap">
      0:
      <AnimatePresence initial={false} mode="popLayout">
        {digits.map((digit, index) => (
          <motion.div
            className="inline-block tabular-nums"
            key={digit + index}
            initial={{ y: "12px", filter: "blur(2px)", opacity: 0 }}
            animate={{ y: "0", filter: "blur(0px)", opacity: 1 }}
            exit={{ y: "-12px", filter: "blur(2px)", opacity: 0 }}
            transition={{ type: "spring", bounce: 0.35 }}
          >
            {digit}
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}

function BellSvg() {
  return (
    <svg
      className="block"
      fill="none"
      height="12.75"
      viewBox="0 0 15 17"
      width="11.25"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M1.17969 13.3125H13.5625C14.2969 13.3125 14.7422 12.9375 14.7422 12.3672C14.7422 11.5859 13.9453 10.8828 13.2734 10.1875C12.7578 9.64844 12.6172 8.53906 12.5547 7.64062C12.5 4.64062 11.7031 2.57812 9.625 1.82812C9.32812 0.804688 8.52344 0 7.36719 0C6.21875 0 5.40625 0.804688 5.11719 1.82812C3.03906 2.57812 2.24219 4.64062 2.1875 7.64062C2.125 8.53906 1.98438 9.64844 1.46875 10.1875C0.789062 10.8828 0 11.5859 0 12.3672C0 12.9375 0.4375 13.3125 1.17969 13.3125ZM7.36719 16.4453C8.69531 16.4453 9.66406 15.4766 9.76562 14.3828H4.97656C5.07812 15.4766 6.04688 16.4453 7.36719 16.4453Z"
        fill="white"
      />
    </svg>
  );
}