Skip to content

Dynamic Island ​

A pill that springs its width, height and radius between live states while the content cross-fades.

9:41

Saturday, November 1

9:41

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/dynamic-island.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/dynamic-island/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, onBeforeUnmount, ref } from 'vue'
import { cn } from '@/lib/utils'

const props = withDefaults(defineProps<{
  /** Name of the active state; the slot with the same name is rendered. */
  state?: string
  /** Largest corner radius (px) once the island grows taller than a pill. */
  radius?: number
  /** Accessible name of the live region. */
  label?: string
  class?: HTMLAttributes['class']
}>(), {
  state: 'idle',
  radius: 32,
  label: 'Live activity',
})

defineSlots<Record<string, () => unknown>>()

const reduced = useReducedMotion()

// The shell's size follows whatever the active slot renders: we measure the
// content box and spring width / height / radius towards it.
const size = ref<{ width: number, height: number } | null>(null)
let observer: ResizeObserver | undefined
let observed: HTMLElement | null = null

function track(el: unknown, state: string) {
  const node = ((el as { $el?: unknown } | null)?.$el ?? el) as HTMLElement | null
  if (!(node instanceof HTMLElement) || state !== props.state || node === observed)
    return
  observed = node
  observer?.disconnect()
  const measure = () => (size.value = { width: node.offsetWidth, height: node.offsetHeight })
  measure()
  if (typeof ResizeObserver !== 'undefined') {
    observer = new ResizeObserver(measure)
    observer.observe(node)
  }
}

onBeforeUnmount(() => observer?.disconnect())

const shell = computed(() => {
  if (!size.value)
    return {}
  const { width, height } = size.value
  return { width, height, borderRadius: Math.min(height / 2, props.radius) }
})

const morph = { type: 'spring', stiffness: 380, damping: 28, mass: 0.9 } as const
const content = computed(() => ({
  initial: reduced.value
    ? { opacity: 0 }
    : { opacity: 0, scale: 0.86, filter: 'blur(8px)' },
  animate: {
    opacity: 1,
    scale: 1,
    filter: 'blur(0px)',
    transition: { delay: reduced.value ? 0 : 0.08, type: 'spring', stiffness: 320, damping: 30 },
  },
  exit: reduced.value
    ? { opacity: 0, transition: { duration: 0.12 } }
    : { opacity: 0, scale: 0.9, filter: 'blur(6px)', transition: { duration: 0.16 } },
}))
</script>

<template>
  <motion.div
    role="status"
    aria-live="polite"
    :aria-label="props.label"
    :data-state="props.state"
    :class="cn(
      'relative isolate mx-auto h-9 w-28 overflow-hidden bg-foreground text-background shadow-lg',
      props.class,
    )"
    :style="{ borderRadius: '18px' }"
    :initial="false"
    :animate="shell"
    :transition="reduced ? { duration: 0 } : morph"
  >
    <AnimatePresence :initial="false">
      <motion.div
        :key="props.state"
        :ref="(el: unknown) => track(el, props.state)"
        class="absolute inset-x-0 top-0 mx-auto w-max"
        :initial="content.initial"
        :animate="content.animate"
        :exit="content.exit"
      >
        <slot :name="props.state" />
      </motion.div>
    </AnimatePresence>
  </motion.div>
</template>
ts
export { default as DynamicIsland } from './DynamicIsland.vue'

Usage ​

ts
import { DynamicIsland } from '@/components/ui/dynamic-island'
vue
<DynamicIsland :state="state">
  <template #idle>
    <div class="h-9 w-32" />
  </template>
  <template #timer>
    <div class="flex h-9 w-56 items-center justify-between px-3">
      <TimerIcon class="size-4" /> 4:32
    </div>
  </template>
  <template #call>
    <div class="flex w-80 items-center gap-3 p-3">…</div>
  </template>
</DynamicIsland>

Each state is a named slot. The island measures whatever the active slot renders and springs its width, height and corner radius to fit, so states only need to size their own content (w-*, h-*, padding). The outgoing content blurs out while the incoming one blurs in on top.

API ​

PropTypeDefaultDescription
statestring'idle'Active state; the slot with this name is rendered.
radiusnumber32Largest corner radius (px). Short states stay a full pill.
labelstring'Live activity'Accessible name of the live region.
SlotDescription
#<state>One slot per state name, e.g. #idle, #call, #music.

The island is a polite live region (role="status"), so state changes are announced. With reduced motion the size snaps and content only fades.

Designed by Kuldeep Pisda · Released under the MIT License