DynamicIsland
Square status island: it springs to the size of its content and blurs new content in whenever that content changes.
Usage
svelte
<script lang="ts">
import { Timer } from "@lucide/svelte";
import { DynamicIsland } from "@milandekruijf/ui/svelte";
let active = $state(false);
</script>
<DynamicIsland
label={active ? "Stop timer" : "Start timer"}
onclick={() => (active = !active)}
>
<Timer />
{#if active}0:10{/if}
</DynamicIsland>Examples
Focus timer
Click the island to start, pause, and reset.
<script lang="ts">
import { onDestroy } from "svelte";
import { css, cx } from "@milandekruijf/pandacss/css";
import { AnimatedCharFlip, DynamicIsland } from "@milandekruijf/ui/svelte";
import { Check, Pause, Timer } from "@lucide/svelte";
type State = "idle" | "running" | "paused" | "done";
/** Short session so the demo can actually finish. */
const DURATION = 10;
const RESET_DELAY = 2400;
let phase = $state<State>("idle");
let remaining = $state(DURATION);
let ticker: ReturnType<typeof setInterval> | null = null;
let resetTimer: ReturnType<typeof setTimeout> | null = null;
function format(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
const time = $derived(format(remaining));
const label = $derived(
phase === "idle"
? "Start focus session"
: phase === "running"
? "Pause focus session"
: phase === "paused"
? "Resume focus session"
: "Reset focus session",
);
function stopTicker() {
if (ticker !== null) clearInterval(ticker);
ticker = null;
}
function stopReset() {
if (resetTimer !== null) clearTimeout(resetTimer);
resetTimer = null;
}
function run() {
stopTicker();
ticker = setInterval(() => {
remaining = Math.max(0, remaining - 1);
if (remaining > 0) return;
stopTicker();
phase = "done";
resetTimer = setTimeout(() => {
resetTimer = null;
phase = "idle";
remaining = DURATION;
}, RESET_DELAY);
}, 1000);
}
function toggle() {
if (phase === "idle") {
remaining = DURATION;
phase = "running";
run();
} else if (phase === "running") {
stopTicker();
phase = "paused";
} else if (phase === "paused") {
phase = "running";
run();
} else {
stopReset();
phase = "idle";
remaining = DURATION;
}
}
onDestroy(() => {
stopTicker();
stopReset();
});
const stageClass = css({
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
py: 6,
w: "100%",
});
const iconClass = css({ w: 5, h: 5, flexShrink: 0 });
const idleIconClass = css({ w: 4, h: 4, color: "slate.11" });
const runningIconClass = css({ color: "blue.11" });
const mutedIconClass = css({ color: "slate.11" });
const doneIconClass = css({ color: "green.11" });
const timeClass = css({
display: "inline",
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
transition: "opacity 0.2s ease-in-out",
});
const dimClass = css({ opacity: 0.5 });
const hintClass = css({
fontSize: "sm",
color: "slate.11",
m: 0,
});
</script>
<div class={stageClass}>
<DynamicIsland {label} onclick={toggle}>
{#if phase === "idle"}
<Timer class={cx(iconClass, idleIconClass)} aria-hidden="true" />
{:else if phase === "done"}
<Check class={cx(iconClass, doneIconClass)} aria-hidden="true" />
<span>Complete</span>
{:else}
{#if phase === "paused"}
<Pause class={cx(iconClass, mutedIconClass)} aria-hidden="true" />
{:else}
<Timer class={cx(iconClass, runningIconClass)} aria-hidden="true" />
{/if}
<span class={cx(timeClass, phase === "paused" && dimClass)}>
{#each [...time] as char, index (index)}
{#if char >= "0" && char <= "9"}
<AnimatedCharFlip {char} />
{:else}
<span>{char}</span>
{/if}
{/each}
</span>
{/if}
</DynamicIsland>
<p class={hintClass}>Click the island to start, pause, and reset.</p>
</div>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| class? | string | — | Extra classes merged on the root. |
| label? | string | — | Accessible name; required once `onclick` makes the island a button. |
| onclick? | (event: MouseEvent) => void | — | When set, the island itself renders as a button, with the whole box as the hit area. |
| children | Snippet | — | Island content; its measured size drives the box. Keep it single-line. |
The island measures its content and springs its box to the new size whenever the content changes, blurring the new side in; changes that keep the same box (e.g. a ticking digit) leave it still. The island already carries `px: 4`, `py: 2`, and its typography; the squared surface matches the site's buttons — `slate.3` behind `slate.12`, lifting to `slate.4`/`slate.5` on hover and press.