niceui

Dynamic Island

A black pill that morphs its width, height and radius between idle, call, music, timer and notification states.

ReactTailwind CSSTypeScriptBlocksMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/blocks-dynamic-island.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/blocks/DynamicIsland.tsx
import { useEffect, useState } from 'react';
import { Bell, Mic, Music, Pause, Phone, PhoneOff, Play, SkipForward, Timer } from 'lucide-react';
import { cn } from '@/lib/cn';

export type IslandMode = 'idle' | 'call' | 'music' | 'timer' | 'notification';

const SIZES: Record<IslandMode, { w: number; h: number; r: number }> = {
  idle: { w: 120, h: 34, r: 17 },
  call: { w: 300, h: 56, r: 28 },
  music: { w: 340, h: 148, r: 40 },
  timer: { w: 220, h: 40, r: 20 },
  notification: { w: 340, h: 76, r: 32 },
};

function Waveform({ playing }: { playing: boolean }) {
  return (
    <span className="flex h-5 items-end gap-[3px]">
      {[0.5, 0.9, 0.6, 1, 0.7].map((s, i) => (
        <span
          key={i}
          className={cn('w-[3px] origin-bottom rounded-full bg-brand-400', playing && 'animate-bar-bounce')}
          style={{ height: `${s * 100}%`, animationDelay: `${i * 120}ms`, animationDuration: `${700 + i * 90}ms`, transform: playing ? undefined : 'scaleY(0.3)' }}
        />
      ))}
    </span>
  );
}

function useSeconds(active: boolean) {
  const [s, setS] = useState(0);
  useEffect(() => {
    if (!active) return setS(0);
    const id = window.setInterval(() => setS((v) => v + 1), 1000);
    return () => window.clearInterval(id);
  }, [active]);
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}

export function DynamicIsland({ mode, onModeChange }: { mode: IslandMode; onModeChange?: (m: IslandMode) => void }) {
  const size = SIZES[mode];
  const [playing, setPlaying] = useState(true);
  const callTime = useSeconds(mode === 'call');
  const [left, setLeft] = useState(300);
  useEffect(() => {
    if (mode !== 'timer') return;
    setLeft(300);
    const id = window.setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
    return () => window.clearInterval(id);
  }, [mode]);

  return (
    <div
      className="relative mx-auto overflow-hidden bg-black text-white shadow-[0_0_0_1px_rgba(255,255,255,0.06),0_12px_32px_-8px_rgba(0,0,0,0.8)]"
      style={{
        width: size.w,
        height: size.h,
        borderRadius: size.r,
        transition: 'width 600ms cubic-bezier(0.34,1.4,0.64,1), height 600ms cubic-bezier(0.34,1.4,0.64,1), border-radius 600ms cubic-bezier(0.34,1.4,0.64,1)',
      }}
    >
      <div key={mode} className="absolute inset-0 flex animate-[fade-in_400ms_ease-out_150ms_both] items-center px-3">
        {mode === 'idle' && <span className="mx-auto h-2 w-2 rounded-full bg-ink-800" />}
        {mode === 'call' && (
          <div className="flex w-full items-center gap-3">
            <span className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-amber-300 to-rose-400 text-sm font-semibold text-ink-950">MC</span>
            <div className="flex-1 leading-tight">
              <p className="text-[11px] text-ink-400">iPhone · {callTime}</p>
              <p className="text-sm font-medium">Mira Chen</p>
            </div>
            <button aria-label="Mute" className="flex h-9 w-9 items-center justify-center rounded-full bg-ink-800 transition-transform active:scale-90"><Mic className="h-4 w-4" /></button>
            <button aria-label="End call" onClick={() => onModeChange?.('idle')} className="flex h-9 w-9 items-center justify-center rounded-full bg-rose-500 transition-transform hover:scale-105 active:scale-90"><PhoneOff className="h-4 w-4" /></button>
          </div>
        )}
        {mode === 'timer' && (
          <div className="flex w-full items-center justify-between px-1">
            <span className="flex h-7 w-7 items-center justify-center rounded-full bg-amber-500/20 text-amber-400"><Timer className="h-3.5 w-3.5" /></span>
            <span className="font-mono text-lg font-medium tabular-nums text-amber-400">{Math.floor(left / 60)}:{String(left % 60).padStart(2, '0')}</span>
          </div>
        )}
        {mode === 'notification' && (
          <div className="flex w-full items-center gap-3 px-1">
            <span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-500"><Bell className="h-5 w-5" /></span>
            <div className="min-w-0 flex-1 leading-tight">
              <p className="text-sm font-medium">Deploy succeeded</p>
              <p className="truncate text-xs text-ink-400">lumen-ui · production is live in 42s</p>
            </div>
            <span className="text-[11px] text-ink-500">now</span>
          </div>
        )}
        {mode === 'music' && (
          <div className="flex w-full flex-col gap-3 px-2">
            <div className="flex items-center gap-3">
              <span className="h-12 w-12 rounded-xl bg-gradient-to-br from-brand-400 via-sky-500 to-teal-400" />
              <div className="flex-1 leading-tight">
                <p className="text-sm font-medium">Midnight City</p>
                <p className="text-xs text-ink-400">M83</p>
              </div>
              <Waveform playing={playing} />
            </div>
            <div className="h-1 overflow-hidden rounded-full bg-white/10">
              <div className={cn('h-full w-1/3 rounded-full bg-white transition-all duration-[4000ms] ease-linear', playing && 'w-2/3')} />
            </div>
            <div className="flex items-center justify-center gap-6">
              <button aria-label={playing ? 'Pause' : 'Play'} onClick={() => setPlaying((p) => !p)} className="transition-transform active:scale-90">
                {playing ? <Pause className="h-5 w-5 fill-current" /> : <Play className="h-5 w-5 fill-current" />}
              </button>
              <button aria-label="Next" className="transition-transform active:scale-90"><SkipForward className="h-5 w-5 fill-current" /></button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

const MODES: Array<{ id: IslandMode; label: string; icon: typeof Phone }> = [
  { id: 'call', label: 'Call', icon: Phone },
  { id: 'music', label: 'Music', icon: Music },
  { id: 'timer', label: 'Timer', icon: Timer },
  { id: 'notification', label: 'Alert', icon: Bell },
];

export function DynamicIslandDemo() {
  const [mode, setMode] = useState<IslandMode>('idle');
  return (
    <div className="flex flex-col items-center gap-10">
      <div className="flex h-40 items-start">
        <DynamicIsland mode={mode} onModeChange={setMode} />
      </div>
      <div className="flex flex-wrap justify-center gap-1.5 rounded-2xl bg-ink-900 p-1.5 shadow-card">
        {MODES.map(({ id, label, icon: Icon }) => (
          <button
            key={id}
            onClick={() => setMode(mode === id ? 'idle' : id)}
            className={cn(
              'focus-ring flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all active:scale-95',
              mode === id ? 'bg-ink-800 text-ink-50 shadow-hairline' : 'text-ink-400 hover:text-ink-100',
            )}
          >
            <Icon className="h-4 w-4" /> {label}
          </button>
        ))}
      </div>
    </div>
  );
}