suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Interactions15
  • Overview
  • Card Stack
  • Image Viewer
  • Island
  • OTP Autofill
  • Preview Menu
  • Pull to Refresh
  • Reorder List
  • Search Reveal
  • Section Index
  • Selection List
  • Stepper
  • Swipe Actions
  • Tip
  • Top Banner
  • Undo Bar

suiss/ui · Interaction

Island

A floating pill at the top of the screen that shows live activities and brief notifications, like the Dynamic Island.

Island is an open-source interaction from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

import { BellIcon, ChatCircleIcon } from "@phosphor-icons/react"
import {
  Island,
  IslandProvider,
  useIsland,
} from "@/components/interactions/island"
import { Button } from "@/components/ui/button"

function NotifyButtons() {
  const { notify } = useIsland()

  return (
    <div className="flex flex-wrap justify-center gap-3">
      <Button
        variant="secondary"
        onClick={() =>
          notify({
            icon: (
              <ChatCircleIcon className="text-green-500 fill-current" />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        New message
      </Button>
      <Button
        variant="secondary"
        onClick={() =>
          notify({
            icon: (
              <BellIcon className="text-orange-500 fill-current" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        Reminder
      </Button>
    </div>
  )
}

export default function IslandNotification() {
  return (
    <IslandProvider>
      <div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 text-center">
        <Island />
        <p className="max-w-xs text-sm text-muted-foreground">
          The pill grows to show the notification and shrinks back on its own.
        </p>
        <NotifyButtons />
      </div>
    </IslandProvider>
  )
}

Island renders a dark pill fixed to the top of the viewport, below the safe-area inset. It morphs between four views: hidden, compact, expanded, and notice. The pill measures the active part and animates its width, height, and corner radius with a spring curve, so content changes feel continuous.

A live activity is an IslandCompact and IslandExpanded pair rendered inside Island. Mounting an IslandCompact shows the pill; tapping it expands to the details. Notifications come from the notify function returned by useIsland; a notice temporarily takes over the pill and then returns to the activity, or hides the pill when no activity is mounted. Use it for ongoing, glanceable status such as downloads, timers, or calls.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/island

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add interactions island

Manual

  1. Add the cn helper.

  2. Copy the source into components/interactions/island.tsx:

components/interactions/island.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

type IslandView = "hidden" | "compact" | "expanded" | "notice"

type IslandNotice = {
  icon?: React.ReactNode
  title: React.ReactNode
  description?: React.ReactNode
  duration?: number
}

type IslandContextProps = {
  view: IslandView
  1. Update the import paths to match your project.

Usage

import { IslandProvider, Island, IslandCompact, IslandExpanded, IslandRing } from "@/components/interactions/island"
<IslandProvider>
  <div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 text-center">
    <Island />
    <p className="max-w-xs text-sm text-muted-foreground">
      The pill grows to show the notification and shrinks back on its own.
    </p>
    <NotifyButtons />
  </div>
</IslandProvider>
  • Wrap the app, or the part of it that posts notifications, in IslandProvider. Island, IslandCompact, IslandExpanded, and useIsland throw outside of it.
  • Render a single Island. Mount IslandCompact and IslandExpanded as its children only while an activity is running; unmounting the compact part hides the pill and collapses it.
  • Call notify({ icon, title, description, duration }) from useIsland to show a notice. It replaces any current notice and dismisses itself after duration milliseconds (4000 by default). Call dismiss to remove it early.
  • useIsland also exposes view, expanded, and setExpanded for custom controls.

Examples

Live activity

A download runs as a live activity, with a progress ring in the compact pill and details plus a cancel button when expanded.

import {
  BellIcon,
  ChatCircleIcon,
  CheckCircleIcon,
  DownloadSimpleIcon,
  FileZipIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  Island,
  IslandCompact,
  IslandExpanded,
  IslandProvider,
  IslandRing,
  useIsland,
} from "@/components/interactions/island"
import { Button } from "@/components/ui/button"

const SIZE = 120

function NotifyButtons() {
  const { notify } = useIsland()

  return (
    <div className="flex flex-wrap justify-center gap-3">
      <Button
        variant="secondary"
        onClick={() =>
          notify({
            icon: (
              <ChatCircleIcon className="text-green-500 fill-current" />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        New message
      </Button>
      <Button
        variant="secondary"
        onClick={() =>
          notify({
            icon: (
              <BellIcon className="text-orange-500 fill-current" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        Reminder
      </Button>
    </div>
  )
}

function useDownload() {
  const [progress, setProgress] = React.useState<number | null>(null)
  const { notify } = useIsland()

  React.useEffect(() => {
    if (progress === null || progress >= 1) return
    const timer = window.setTimeout(
      () => setProgress((value) => Math.min((value ?? 0) + 0.01, 1)),
      150
    )
    return () => window.clearTimeout(timer)
  }, [progress])

  React.useEffect(() => {
    if (progress !== 1) return
    const timer = window.setTimeout(() => {
      setProgress(null)
      notify({
        icon: (
          <CheckCircleIcon className="fill-green-500 text-white" />
        ),
        title: "Download complete",
        description: "Design-Kit.zip",
      })
    }, 1200)
    return () => window.clearTimeout(timer)
  }, [progress, notify])

  return {
    progress,
    start: () => setProgress(0),
    cancel: () => setProgress(null),
  }
}

function DownloadActivity({
  progress,
  onCancel,
}: {
  progress: number
  onCancel: () => void
}) {
  const done = progress >= 1
  const percent = Math.round(progress * 100)

  return (
    <>
      <IslandCompact aria-label={`Downloading Design-Kit.zip, ${percent}%`}>
        <DownloadSimpleIcon className="text-primary" />
        {done ? (
          <CheckCircleIcon className="fill-green-500 text-white" />
        ) : (
          <IslandRing value={progress} data-visual-mask />
        )}
      </IslandCompact>
      <IslandExpanded aria-label="Download">
        <div className="flex items-center gap-3">
          <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-background/15 dark:bg-foreground/15">
            <FileZipIcon className="size-6 text-primary" />
          </span>
          <span className="flex min-w-0 flex-1 flex-col">
            <span className="truncate text-sm font-semibold">
              Design-Kit.zip
            </span>
            <span className="text-sm text-background/70 tabular-nums dark:text-muted-foreground">
              {done
                ? "Downloaded"
                : `${Math.round(progress * SIZE)} MB of ${SIZE} MB`}
            </span>
          </span>
          <span className="text-2xl font-semibold tabular-nums">
            {percent}%
          </span>
        </div>
        <div
          role="progressbar"
          aria-label="Download progress"
          aria-valuenow={percent}
          aria-valuemin={0}
          aria-valuemax={100}
          className="h-1.5 overflow-hidden rounded-full bg-background/20 dark:bg-foreground/20"
        >
          <div
            className="h-full origin-left rounded-full bg-primary transition-[scale] duration-300 rtl:origin-right"
            style={{ scale: `${progress} 1` }}
          />
        </div>
        <button
          type="button"
          onClick={onCancel}
          disabled={done}
          className="h-11 rounded-full bg-background/15 text-sm font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 dark:bg-foreground/15"
        >
          Cancel download
        </button>
      </IslandExpanded>
    </>
  )
}

function DownloadsExample({
  withNotifications,
}: {
  withNotifications?: boolean
}) {
  const { progress, start, cancel } = useDownload()

  return (
    <div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 text-center">
      <Island>
        {progress !== null && (
          <DownloadActivity progress={progress} onCancel={cancel} />
        )}
      </Island>
      <p className="max-w-xs text-sm text-muted-foreground">
        Start a download, then tap the pill at the top to see the details.
      </p>
      <Button onClick={start} disabled={progress !== null}>
        {progress === null ? "Download Design-Kit.zip" : "Downloading…"}
      </Button>
      {withNotifications && <NotifyButtons />}
    </div>
  )
}

export default function IslandLiveActivity() {
  return (
    <IslandProvider>
      <DownloadsExample />
    </IslandProvider>
  )
}

Activity with notifications

A notification temporarily replaces a running live activity, which returns once the notice is dismissed.

import {
  BellIcon,
  ChatCircleIcon,
  CheckCircleIcon,
  DownloadSimpleIcon,
  FileZipIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  Island,
  IslandCompact,
  IslandExpanded,
  IslandProvider,
  IslandRing,
  useIsland,
} from "@/components/interactions/island"
import { Button } from "@/components/ui/button"

const SIZE = 120

function NotifyButtons() {
  const { notify } = useIsland()

  return (
    <div className="flex flex-wrap justify-center gap-3">
      <Button
        variant="secondary"
        onClick={() =>
          notify({
            icon: (
              <ChatCircleIcon className="text-green-500 fill-current" />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        New message
      </Button>
      <Button
        variant="secondary"
        onClick={() =>
          notify({
            icon: (
              <BellIcon className="text-orange-500 fill-current" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        Reminder
      </Button>
    </div>
  )
}

function useDownload() {
  const [progress, setProgress] = React.useState<number | null>(null)
  const { notify } = useIsland()

  React.useEffect(() => {
    if (progress === null || progress >= 1) return
    const timer = window.setTimeout(
      () => setProgress((value) => Math.min((value ?? 0) + 0.01, 1)),
      150
    )
    return () => window.clearTimeout(timer)
  }, [progress])

  React.useEffect(() => {
    if (progress !== 1) return
    const timer = window.setTimeout(() => {
      setProgress(null)
      notify({
        icon: (
          <CheckCircleIcon className="fill-green-500 text-white" />
        ),
        title: "Download complete",
        description: "Design-Kit.zip",
      })
    }, 1200)
    return () => window.clearTimeout(timer)
  }, [progress, notify])

  return {
    progress,
    start: () => setProgress(0),
    cancel: () => setProgress(null),
  }
}

function DownloadActivity({
  progress,
  onCancel,
}: {
  progress: number
  onCancel: () => void
}) {
  const done = progress >= 1
  const percent = Math.round(progress * 100)

  return (
    <>
      <IslandCompact aria-label={`Downloading Design-Kit.zip, ${percent}%`}>
        <DownloadSimpleIcon className="text-primary" />
        {done ? (
          <CheckCircleIcon className="fill-green-500 text-white" />
        ) : (
          <IslandRing value={progress} data-visual-mask />
        )}
      </IslandCompact>
      <IslandExpanded aria-label="Download">
        <div className="flex items-center gap-3">
          <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-background/15 dark:bg-foreground/15">
            <FileZipIcon className="size-6 text-primary" />
          </span>
          <span className="flex min-w-0 flex-1 flex-col">
            <span className="truncate text-sm font-semibold">
              Design-Kit.zip
            </span>
            <span className="text-sm text-background/70 tabular-nums dark:text-muted-foreground">
              {done
                ? "Downloaded"
                : `${Math.round(progress * SIZE)} MB of ${SIZE} MB`}
            </span>
          </span>
          <span className="text-2xl font-semibold tabular-nums">
            {percent}%
          </span>
        </div>
        <div
          role="progressbar"
          aria-label="Download progress"
          aria-valuenow={percent}
          aria-valuemin={0}
          aria-valuemax={100}
          className="h-1.5 overflow-hidden rounded-full bg-background/20 dark:bg-foreground/20"
        >
          <div
            className="h-full origin-left rounded-full bg-primary transition-[scale] duration-300 rtl:origin-right"
            style={{ scale: `${progress} 1` }}
          />
        </div>
        <button
          type="button"
          onClick={onCancel}
          disabled={done}
          className="h-11 rounded-full bg-background/15 text-sm font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 dark:bg-foreground/15"
        >
          Cancel download
        </button>
      </IslandExpanded>
    </>
  )
}

function DownloadsExample({
  withNotifications,
}: {
  withNotifications?: boolean
}) {
  const { progress, start, cancel } = useDownload()

  return (
    <div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 text-center">
      <Island>
        {progress !== null && (
          <DownloadActivity progress={progress} onCancel={cancel} />
        )}
      </Island>
      <p className="max-w-xs text-sm text-muted-foreground">
        Start a download, then tap the pill at the top to see the details.
      </p>
      <Button onClick={start} disabled={progress !== null}>
        {progress === null ? "Download Design-Kit.zip" : "Downloading…"}
      </Button>
      {withNotifications && <NotifyButtons />}
    </div>
  )
}

export default function IslandActivityWithNotifications() {
  return (
    <IslandProvider>
      <DownloadsExample withNotifications />
    </IslandProvider>
  )
}

API Reference

Anatomy2 parts
<IslandProvider>
<Island />
</IslandProvider>
01

IslandProvider

Holds the island state and provides notify, dismiss, and the expanded state to everything inside it.

PropTypeDefaultDescription
childrenReact.ReactNode-The content that can render the island and post notifications.
02

Island

The fixed pill that sizes itself to the active view and renders the built-in notice.

Also accepts the props of React.ComponentProps<"div">.

03

IslandCompact

The button shown in the collapsed pill; tapping it expands the island.

Also accepts the props of React.ComponentProps<"button">.

04

IslandExpanded

The detailed view shown when the island is expanded.

Also accepts the props of React.ComponentProps<"div">.

05

IslandRing

A small circular progress ring for the compact pill.

PropTypeDefaultDescription
valuenumberRequiredProgress from 0 to 1; values outside the range are clamped.

Also accepts the props of React.ComponentProps<"svg">.

Accessibility

  • IslandCompact is a button with aria-expanded reflecting the expanded state; give it an aria-label that describes the activity.
  • Expanding moves focus to the first enabled button, link, or input inside IslandExpanded, which has role="group" and is inert while not shown.
  • Escape or a pointer press outside collapses the expanded view. After Escape, focus returns to the compact button if it was inside the island.
  • Notices render as a button that dismisses on press, and their title and description are announced through a polite live region.
  • Inactive parts are removed from the tab order. IslandRing is aria-hidden.

Notes

  • The root exposes data-slot="island" and data-view with the current view. Parts use data-slot values island-compact, island-expanded, island-notice, and island-ring, and carry data-active while shown.
  • Animations are disabled under prefers-reduced-motion.
iOS
Island (compact) on iOS in light modeIsland (compact) on iOS in dark mode
import { BellIcon, ChatCircleIcon, CheckCircleIcon, DownloadSimpleIcon, FileZipIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { Island, IslandExpandedRegion, IslandProvider, IslandRing, useIsland } from "@/components/interactions/island"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

function NotifyButtons({ children }: { children?: React.ReactNode }) {
  const { notify } = useIsland()

  return (
    <View className="flex-row flex-wrap justify-center gap-3">
      {children}
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon
                as={ChatCircleIcon}
                weight="fill"
                className="text-green-500"
              />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        <Text>New message</Text>
      </Button>
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon as={BellIcon} weight="fill" className="text-orange-500" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        <Text>Reminder</Text>
      </Button>
    </View>
  )
}

function useDownload(initial: number | null = null) {
  const [progress, setProgress] = React.useState<number | null>(initial)
  const { notify } = useIsland()

  React.useEffect(() => {
    if (initial !== null || progress === null || progress >= 1) return
    const timer = setTimeout(
      () => setProgress((value) => Math.min((value ?? 0) + 0.01, 1)),
      150
    )
    return () => clearTimeout(timer)
  }, [initial, progress])

  React.useEffect(() => {
    if (progress !== 1) return
    const timer = setTimeout(() => {
      setProgress(null)
      notify({
        icon: (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ),
        title: "Download complete",
        description: "Design-Kit.zip",
      })
    }, 1200)
    return () => clearTimeout(timer)
  }, [progress, notify])

  return {
    progress,
    start: () => setProgress(0),
    cancel: () => setProgress(null),
  }
}

function DownloadIsland({
  progress,
  onCancel,
}: {
  progress: number | null
  onCancel: () => void
}) {
  if (progress === null) return <Island />

  const done = progress >= 1
  const percent = Math.round(progress * 100)

  return (
    <Island
      accessibilityLabel={`Downloading Design-Kit.zip, ${percent}%`}
      compactLeading={
        <Icon as={DownloadSimpleIcon} weight="bold" className="text-primary" />
      }
      compactTrailing={
        done ? (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ) : (
          <IslandRing value={progress} />
        )
      }
      minimal={<IslandRing value={progress} />}
    >
      <IslandExpandedRegion region="leading">
        <View className="size-10 items-center justify-center rounded-xl bg-white/15">
          <Icon
            as={FileZipIcon}
            weight="fill"
            className="size-6 text-primary"
          />
        </View>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="trailing">
        <Text className="text-2xl font-semibold">{percent}%</Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="center">
        <Text numberOfLines={1} className="text-sm font-semibold">
          {done ? "Downloaded" : "Downloading"}
        </Text>
        <Text numberOfLines={1} className="text-sm text-white/70">
          Design-Kit.zip
        </Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="bottom">
        <View
          role="progressbar"
          accessibilityLabel="Download progress"
          accessibilityValue={{ min: 0, max: 100, now: percent }}
          className="h-1.5 overflow-hidden rounded-full bg-white/20"
        >
          <View
            className="h-full rounded-full bg-primary"
            style={{ width: `${percent}%` }}
          />
        </View>
        <Pressable
          role="button"
          disabled={done}
          onPress={onCancel}
          className="h-11 items-center justify-center rounded-full bg-white/15 active:opacity-80 disabled:opacity-50"
        >
          <Text className="text-sm font-semibold">Cancel download</Text>
        </Pressable>
      </IslandExpandedRegion>
    </Island>
  )
}

function DownloadsExample({
  initial = null,
  view = "compact",
  withNotifications = false,
}: {
  initial?: number | null
  view?: "compact" | "expanded" | "minimal"
  withNotifications?: boolean
}) {
  const { progress, start, cancel } = useDownload(initial)
  const { expand, minimize } = useIsland()

  React.useEffect(() => {
    if (view === "expanded") expand()
    else if (view === "minimal") minimize()
  }, [view, expand, minimize])

  return (
    <View className="flex-1 items-center justify-center gap-4">
      <DownloadIsland progress={progress} onCancel={cancel} />
      <Text className="max-w-xs text-center text-sm text-muted-foreground">
        Start a download, then tap the pill at the top to see the details. Swipe
        up on it to close.
      </Text>
      {initial === null ? (
        <Button onPress={start} disabled={progress !== null}>
          <Text>
            {progress === null ? "Download Design-Kit.zip" : "Downloading…"}
          </Text>
        </Button>
      ) : null}
      {withNotifications ? (
        <NotifyButtons>
          <Button
            variant="secondary"
            disabled={progress === null}
            onPress={minimize}
          >
            <Text>Minimize</Text>
          </Button>
        </NotifyButtons>
      ) : null}
    </View>
  )
}

export function IslandCompactExample() {
  return (
    <IslandProvider>
      <DownloadsExample initial={0.42} />
    </IslandProvider>
  )
}
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.bold.DownloadSimple
import com.adamglin.phosphoricons.fill.Bell
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.CheckCircle
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.Island
import com.esuiss.ui.interactions.IslandProvider
import com.esuiss.ui.interactions.IslandRegion
import com.esuiss.ui.interactions.IslandRing
import com.esuiss.ui.interactions.useIsland
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlin.math.roundToInt

@Composable
fun IslandCompactExample() {
    IslandProvider { IslandDownloads(initial = 0.42f) }
}

enum class IslandStart { Compact, Expanded, Minimal }

@Composable
fun IslandNotifyButtons(extra: @Composable () -> Unit = {}) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    FlowRow(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Button(
            "New message",
            onClick = {
                island.notify(
                    title = "Jordan Lee",
                    description = "Are we still on for Saturday?",
                    icon = { Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, tint = Palette.green500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        Button(
            "Reminder",
            onClick = {
                island.notify(
                    title = "Design review",
                    description = "Starts in 10 minutes",
                    icon = { Icon(PhosphorIcons.Fill.Bell, contentDescription = null, tint = Palette.orange500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        extra()
    }
}

@Composable
fun IslandProgress(progress: Float) {
    val colors = EsuissTheme.colors
    if (progress >= 1f) {
        Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500)
    } else {
        IslandRing(progress)
    }
}

@Composable
fun IslandDownloadIsland(progress: Float?, onCancel: () -> Unit) {
    val colors = EsuissTheme.colors
    val current = progress ?: 0f
    val done = current >= 1f
    val percent = (current * 100).roundToInt()
    Island(
        compactLeading = progress?.let {
            { Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, tint = colors.primary) }
        },
        compactTrailing = progress?.let { value -> { IslandProgress(value) } },
        minimal = progress?.let { value -> { IslandProgress(value) } },
        modifier = Modifier.semantics { contentDescription = "Downloading Design-Kit.zip, $percent%" },
    ) {
        IslandExpandedRegion(IslandRegion.Leading) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .background(foreground.copy(alpha = 0.15f), RoundedCornerShape(Radius.xl)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, size = 24.dp, tint = colors.primary)
            }
        }
        IslandExpandedRegion(IslandRegion.Trailing) {
            Text("$percent%", style = Typography.xl2.copy(color = LocalContentColor.current, fontWeight = FontWeight.SemiBold))
        }
        IslandExpandedRegion(IslandRegion.Center) {
            val foreground = LocalContentColor.current
            Text(
                if (done) "Downloaded" else "Downloading",
                style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            Text(
                if (done) "Design-Kit.zip" else "Design-Kit.zip · ${(current * IslandSize).roundToInt()} of $IslandSize MB",
                style = Typography.sm.copy(color = foreground.copy(alpha = 0.7f)),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        IslandExpandedRegion(IslandRegion.Bottom) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(6.dp)
                    .clip(CircleShape)
                    .background(foreground.copy(alpha = 0.2f))
                    .semantics {
                        contentDescription = "Download progress"
                        progressBarRangeInfo = ProgressBarRangeInfo(percent.toFloat(), 0f..100f)
                    },
            ) {
                Box(
                    Modifier
                        .fillMaxWidth(current.coerceIn(0f, 1f))
                        .fillMaxHeight()
                        .background(colors.primary, CircleShape),
                )
            }
            Spacer(Modifier.height(12.dp))
            val interaction = remember { MutableInteractionSource() }
            val pressed by interaction.collectIsPressedAsState()
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(44.dp)
                    .alpha(if (done) 0.5f else 1f)
                    .clip(CircleShape)
                    .alpha(if (pressed) 0.8f else 1f)
                    .background(foreground.copy(alpha = 0.15f))
                    .clickable(interactionSource = interaction, indication = null, enabled = !done, role = Role.Button, onClick = onCancel),
                contentAlignment = Alignment.Center,
            ) {
                Text("Cancel download", style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold))
            }
        }
    }
}

@Composable
fun IslandDownloads(initial: Float? = null, start: IslandStart = IslandStart.Compact, withNotifications: Boolean = false) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    var progress by rememberSaveable { mutableStateOf(initial) }
    LaunchedEffect(progress) {
        val current = progress ?: return@LaunchedEffect
        if (initial != null) return@LaunchedEffect
        if (current < 1f) {
            delay(150)
            progress = minOf(current + 0.01f, 1f)
        } else {
            delay(1200)
            progress = null
            island.notify(
                title = "Download complete",
                description = "Design-Kit.zip",
                icon = { Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500) },
            )
        }
    }
    LaunchedEffect(start) {
        when (start) {
            IslandStart.Expanded -> island.expand()
            IslandStart.Minimal -> island.minimize()
            IslandStart.Compact -> Unit
        }
    }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        IslandDownloadIsland(progress) { progress = null }
        Text(
            "Start a download, then tap the pill at the top to see the details. Swipe up on it to close.",
            modifier = Modifier.widthIn(max = 320.dp),
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
        if (initial == null) {
            Button(
                if (progress == null) "Download Design-Kit.zip" else "Downloading…",
                onClick = { progress = 0f },
                enabled = progress == null,
            )
        }
        if (withNotifications) {
            IslandNotifyButtons {
                Button(
                    "Minimize",
                    onClick = island::minimize,
                    variant = ButtonVariant.Secondary,
                    enabled = progress != null,
                )
            }
        }
    }
}

Island floats below the top safe area and morphs between views: hidden, compact, minimal, expanded and notice. Its width, height and corner radius spring to the measured size of the active view, and views cross-fade as it changes.

Content is passed through slots. compactLeading and compactTrailing sit at the two ends of the compact pill, minimal is a single small circle, and IslandExpandedRegion children fill the expanded view. The island appears while it has compact or minimal content and hides when it has none. Pressing it expands; pressing outside, swiping up or the Android back button collapses it. notify shows a notice that replaces the current view and dismisses itself after 4 seconds, on press or on swipe up. Use it for ongoing activity such as a download, timer or call.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/island

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add island
$ pnpm dlx @esuiss/ui@latest add island --platform swift
$ pnpm dlx @esuiss/ui@latest add island --platform kotlin

The CLI also adds what it builds on: Icon, Text. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/hooks.tsx, components/primitives/portal.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/island.tsx:
uniwind/components/interactions/island.tsx
import { cn } from "cn"
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  AccessibilityInfo,
  Pressable,
  useWindowDimensions,
  View,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  ReduceMotion,
  runOnJS,
  type SharedValue,
  useAnimatedStyle,
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/hooks.tsx, components/primitives/portal.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/island.tsx:
nativewind/components/interactions/island.tsx
import { cn } from "cn"
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  AccessibilityInfo,
  Pressable,
  useWindowDimensions,
  View,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  ReduceMotion,
  runOnJS,
  type SharedValue,
  useAnimatedStyle,
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/hooks.tsx, components/primitives/portal.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/island.tsx:
stylesheet/components/interactions/island.tsx
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  AccessibilityInfo,
  Pressable,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  useWindowDimensions,
  View,
  type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  ReduceMotion,
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Copy the theme files every view uses:

swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source:
swift/Interactions/Island.swift
import SwiftUI

enum IslandMath {
  static func ringProgress(_ value: Double) -> Double {
    min(max(value, 0), 1)
  }
}

public struct IslandRing: View {
  let value: Double
  let color: Color

  public init(value: Double, color: Color = EsuissColor.primary) {
    self.value = value
    self.color = color
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/interactions/Island.kt
package com.esuiss.ui.interactions

import android.graphics.RectF
import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowInsets as PlatformWindowInsets
import android.view.WindowInsetsController
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Screen.kt
package com.esuiss.ui.components

import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowInsetsController
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.ColumnScope
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { IslandProvider, IslandExpandedRegion, Island, IslandRing } from "@/components/interactions/island"
<IslandProvider>
  <DownloadsExample initial={0.42} />
</IslandProvider>

Wrap the app in IslandProvider and render one Island. Pass compactLeading, compactTrailing and minimal only while there is activity to show, and add IslandExpandedRegions as children with a region of leading, trailing, center or bottom. Leading, center and trailing form a top row; bottom regions stack below it at full width. If only one of the compact and minimal slots is given, the island uses it for both views.

useIsland returns view, notice, expand, collapse, minimize, notify and dismiss. A notice is { icon?, title, description?, duration? }; pass a duration of 0 or Infinity to keep it until dismissed. Dragging the island pauses the notice timer. Use IslandRing for circular progress. The IslandNotice, IslandRegion and IslandView types are exported.

Examples

Expanded

Starts with the island expanded to show download details.

iOS
Island (expanded) on iOS in light modeIsland (expanded) on iOS in dark mode
import { BellIcon, ChatCircleIcon, CheckCircleIcon, DownloadSimpleIcon, FileZipIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { Island, IslandExpandedRegion, IslandProvider, IslandRing, useIsland } from "@/components/interactions/island"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

function NotifyButtons({ children }: { children?: React.ReactNode }) {
  const { notify } = useIsland()

  return (
    <View className="flex-row flex-wrap justify-center gap-3">
      {children}
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon
                as={ChatCircleIcon}
                weight="fill"
                className="text-green-500"
              />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        <Text>New message</Text>
      </Button>
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon as={BellIcon} weight="fill" className="text-orange-500" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        <Text>Reminder</Text>
      </Button>
    </View>
  )
}

function useDownload(initial: number | null = null) {
  const [progress, setProgress] = React.useState<number | null>(initial)
  const { notify } = useIsland()

  React.useEffect(() => {
    if (initial !== null || progress === null || progress >= 1) return
    const timer = setTimeout(
      () => setProgress((value) => Math.min((value ?? 0) + 0.01, 1)),
      150
    )
    return () => clearTimeout(timer)
  }, [initial, progress])

  React.useEffect(() => {
    if (progress !== 1) return
    const timer = setTimeout(() => {
      setProgress(null)
      notify({
        icon: (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ),
        title: "Download complete",
        description: "Design-Kit.zip",
      })
    }, 1200)
    return () => clearTimeout(timer)
  }, [progress, notify])

  return {
    progress,
    start: () => setProgress(0),
    cancel: () => setProgress(null),
  }
}

function DownloadIsland({
  progress,
  onCancel,
}: {
  progress: number | null
  onCancel: () => void
}) {
  if (progress === null) return <Island />

  const done = progress >= 1
  const percent = Math.round(progress * 100)

  return (
    <Island
      accessibilityLabel={`Downloading Design-Kit.zip, ${percent}%`}
      compactLeading={
        <Icon as={DownloadSimpleIcon} weight="bold" className="text-primary" />
      }
      compactTrailing={
        done ? (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ) : (
          <IslandRing value={progress} />
        )
      }
      minimal={<IslandRing value={progress} />}
    >
      <IslandExpandedRegion region="leading">
        <View className="size-10 items-center justify-center rounded-xl bg-white/15">
          <Icon
            as={FileZipIcon}
            weight="fill"
            className="size-6 text-primary"
          />
        </View>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="trailing">
        <Text className="text-2xl font-semibold">{percent}%</Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="center">
        <Text numberOfLines={1} className="text-sm font-semibold">
          {done ? "Downloaded" : "Downloading"}
        </Text>
        <Text numberOfLines={1} className="text-sm text-white/70">
          Design-Kit.zip
        </Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="bottom">
        <View
          role="progressbar"
          accessibilityLabel="Download progress"
          accessibilityValue={{ min: 0, max: 100, now: percent }}
          className="h-1.5 overflow-hidden rounded-full bg-white/20"
        >
          <View
            className="h-full rounded-full bg-primary"
            style={{ width: `${percent}%` }}
          />
        </View>
        <Pressable
          role="button"
          disabled={done}
          onPress={onCancel}
          className="h-11 items-center justify-center rounded-full bg-white/15 active:opacity-80 disabled:opacity-50"
        >
          <Text className="text-sm font-semibold">Cancel download</Text>
        </Pressable>
      </IslandExpandedRegion>
    </Island>
  )
}

function DownloadsExample({
  initial = null,
  view = "compact",
  withNotifications = false,
}: {
  initial?: number | null
  view?: "compact" | "expanded" | "minimal"
  withNotifications?: boolean
}) {
  const { progress, start, cancel } = useDownload(initial)
  const { expand, minimize } = useIsland()

  React.useEffect(() => {
    if (view === "expanded") expand()
    else if (view === "minimal") minimize()
  }, [view, expand, minimize])

  return (
    <View className="flex-1 items-center justify-center gap-4">
      <DownloadIsland progress={progress} onCancel={cancel} />
      <Text className="max-w-xs text-center text-sm text-muted-foreground">
        Start a download, then tap the pill at the top to see the details. Swipe
        up on it to close.
      </Text>
      {initial === null ? (
        <Button onPress={start} disabled={progress !== null}>
          <Text>
            {progress === null ? "Download Design-Kit.zip" : "Downloading…"}
          </Text>
        </Button>
      ) : null}
      {withNotifications ? (
        <NotifyButtons>
          <Button
            variant="secondary"
            disabled={progress === null}
            onPress={minimize}
          >
            <Text>Minimize</Text>
          </Button>
        </NotifyButtons>
      ) : null}
    </View>
  )
}

export function IslandExpandedExample() {
  return (
    <IslandProvider>
      <DownloadsExample initial={0.42} view="expanded" />
    </IslandProvider>
  )
}
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.bold.DownloadSimple
import com.adamglin.phosphoricons.fill.Bell
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.CheckCircle
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.Island
import com.esuiss.ui.interactions.IslandProvider
import com.esuiss.ui.interactions.IslandRegion
import com.esuiss.ui.interactions.IslandRing
import com.esuiss.ui.interactions.useIsland
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlin.math.roundToInt

@Composable
fun IslandExpandedExample() {
    IslandProvider { IslandDownloads(initial = 0.42f, start = IslandStart.Expanded) }
}

enum class IslandStart { Compact, Expanded, Minimal }

@Composable
fun IslandNotifyButtons(extra: @Composable () -> Unit = {}) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    FlowRow(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Button(
            "New message",
            onClick = {
                island.notify(
                    title = "Jordan Lee",
                    description = "Are we still on for Saturday?",
                    icon = { Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, tint = Palette.green500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        Button(
            "Reminder",
            onClick = {
                island.notify(
                    title = "Design review",
                    description = "Starts in 10 minutes",
                    icon = { Icon(PhosphorIcons.Fill.Bell, contentDescription = null, tint = Palette.orange500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        extra()
    }
}

@Composable
fun IslandProgress(progress: Float) {
    val colors = EsuissTheme.colors
    if (progress >= 1f) {
        Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500)
    } else {
        IslandRing(progress)
    }
}

@Composable
fun IslandDownloadIsland(progress: Float?, onCancel: () -> Unit) {
    val colors = EsuissTheme.colors
    val current = progress ?: 0f
    val done = current >= 1f
    val percent = (current * 100).roundToInt()
    Island(
        compactLeading = progress?.let {
            { Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, tint = colors.primary) }
        },
        compactTrailing = progress?.let { value -> { IslandProgress(value) } },
        minimal = progress?.let { value -> { IslandProgress(value) } },
        modifier = Modifier.semantics { contentDescription = "Downloading Design-Kit.zip, $percent%" },
    ) {
        IslandExpandedRegion(IslandRegion.Leading) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .background(foreground.copy(alpha = 0.15f), RoundedCornerShape(Radius.xl)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, size = 24.dp, tint = colors.primary)
            }
        }
        IslandExpandedRegion(IslandRegion.Trailing) {
            Text("$percent%", style = Typography.xl2.copy(color = LocalContentColor.current, fontWeight = FontWeight.SemiBold))
        }
        IslandExpandedRegion(IslandRegion.Center) {
            val foreground = LocalContentColor.current
            Text(
                if (done) "Downloaded" else "Downloading",
                style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            Text(
                if (done) "Design-Kit.zip" else "Design-Kit.zip · ${(current * IslandSize).roundToInt()} of $IslandSize MB",
                style = Typography.sm.copy(color = foreground.copy(alpha = 0.7f)),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        IslandExpandedRegion(IslandRegion.Bottom) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(6.dp)
                    .clip(CircleShape)
                    .background(foreground.copy(alpha = 0.2f))
                    .semantics {
                        contentDescription = "Download progress"
                        progressBarRangeInfo = ProgressBarRangeInfo(percent.toFloat(), 0f..100f)
                    },
            ) {
                Box(
                    Modifier
                        .fillMaxWidth(current.coerceIn(0f, 1f))
                        .fillMaxHeight()
                        .background(colors.primary, CircleShape),
                )
            }
            Spacer(Modifier.height(12.dp))
            val interaction = remember { MutableInteractionSource() }
            val pressed by interaction.collectIsPressedAsState()
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(44.dp)
                    .alpha(if (done) 0.5f else 1f)
                    .clip(CircleShape)
                    .alpha(if (pressed) 0.8f else 1f)
                    .background(foreground.copy(alpha = 0.15f))
                    .clickable(interactionSource = interaction, indication = null, enabled = !done, role = Role.Button, onClick = onCancel),
                contentAlignment = Alignment.Center,
            ) {
                Text("Cancel download", style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold))
            }
        }
    }
}

@Composable
fun IslandDownloads(initial: Float? = null, start: IslandStart = IslandStart.Compact, withNotifications: Boolean = false) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    var progress by rememberSaveable { mutableStateOf(initial) }
    LaunchedEffect(progress) {
        val current = progress ?: return@LaunchedEffect
        if (initial != null) return@LaunchedEffect
        if (current < 1f) {
            delay(150)
            progress = minOf(current + 0.01f, 1f)
        } else {
            delay(1200)
            progress = null
            island.notify(
                title = "Download complete",
                description = "Design-Kit.zip",
                icon = { Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500) },
            )
        }
    }
    LaunchedEffect(start) {
        when (start) {
            IslandStart.Expanded -> island.expand()
            IslandStart.Minimal -> island.minimize()
            IslandStart.Compact -> Unit
        }
    }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        IslandDownloadIsland(progress) { progress = null }
        Text(
            "Start a download, then tap the pill at the top to see the details. Swipe up on it to close.",
            modifier = Modifier.widthIn(max = 320.dp),
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
        if (initial == null) {
            Button(
                if (progress == null) "Download Design-Kit.zip" else "Downloading…",
                onClick = { progress = 0f },
                enabled = progress == null,
            )
        }
        if (withNotifications) {
            IslandNotifyButtons {
                Button(
                    "Minimize",
                    onClick = island::minimize,
                    variant = ButtonVariant.Secondary,
                    enabled = progress != null,
                )
            }
        }
    }
}

Minimal

Starts in the minimal view with only the progress ring.

iOS
Island (minimal) on iOS in light modeIsland (minimal) on iOS in dark mode
import { BellIcon, ChatCircleIcon, CheckCircleIcon, DownloadSimpleIcon, FileZipIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { Island, IslandExpandedRegion, IslandProvider, IslandRing, useIsland } from "@/components/interactions/island"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

function NotifyButtons({ children }: { children?: React.ReactNode }) {
  const { notify } = useIsland()

  return (
    <View className="flex-row flex-wrap justify-center gap-3">
      {children}
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon
                as={ChatCircleIcon}
                weight="fill"
                className="text-green-500"
              />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        <Text>New message</Text>
      </Button>
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon as={BellIcon} weight="fill" className="text-orange-500" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        <Text>Reminder</Text>
      </Button>
    </View>
  )
}

function useDownload(initial: number | null = null) {
  const [progress, setProgress] = React.useState<number | null>(initial)
  const { notify } = useIsland()

  React.useEffect(() => {
    if (initial !== null || progress === null || progress >= 1) return
    const timer = setTimeout(
      () => setProgress((value) => Math.min((value ?? 0) + 0.01, 1)),
      150
    )
    return () => clearTimeout(timer)
  }, [initial, progress])

  React.useEffect(() => {
    if (progress !== 1) return
    const timer = setTimeout(() => {
      setProgress(null)
      notify({
        icon: (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ),
        title: "Download complete",
        description: "Design-Kit.zip",
      })
    }, 1200)
    return () => clearTimeout(timer)
  }, [progress, notify])

  return {
    progress,
    start: () => setProgress(0),
    cancel: () => setProgress(null),
  }
}

function DownloadIsland({
  progress,
  onCancel,
}: {
  progress: number | null
  onCancel: () => void
}) {
  if (progress === null) return <Island />

  const done = progress >= 1
  const percent = Math.round(progress * 100)

  return (
    <Island
      accessibilityLabel={`Downloading Design-Kit.zip, ${percent}%`}
      compactLeading={
        <Icon as={DownloadSimpleIcon} weight="bold" className="text-primary" />
      }
      compactTrailing={
        done ? (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ) : (
          <IslandRing value={progress} />
        )
      }
      minimal={<IslandRing value={progress} />}
    >
      <IslandExpandedRegion region="leading">
        <View className="size-10 items-center justify-center rounded-xl bg-white/15">
          <Icon
            as={FileZipIcon}
            weight="fill"
            className="size-6 text-primary"
          />
        </View>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="trailing">
        <Text className="text-2xl font-semibold">{percent}%</Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="center">
        <Text numberOfLines={1} className="text-sm font-semibold">
          {done ? "Downloaded" : "Downloading"}
        </Text>
        <Text numberOfLines={1} className="text-sm text-white/70">
          Design-Kit.zip
        </Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="bottom">
        <View
          role="progressbar"
          accessibilityLabel="Download progress"
          accessibilityValue={{ min: 0, max: 100, now: percent }}
          className="h-1.5 overflow-hidden rounded-full bg-white/20"
        >
          <View
            className="h-full rounded-full bg-primary"
            style={{ width: `${percent}%` }}
          />
        </View>
        <Pressable
          role="button"
          disabled={done}
          onPress={onCancel}
          className="h-11 items-center justify-center rounded-full bg-white/15 active:opacity-80 disabled:opacity-50"
        >
          <Text className="text-sm font-semibold">Cancel download</Text>
        </Pressable>
      </IslandExpandedRegion>
    </Island>
  )
}

function DownloadsExample({
  initial = null,
  view = "compact",
  withNotifications = false,
}: {
  initial?: number | null
  view?: "compact" | "expanded" | "minimal"
  withNotifications?: boolean
}) {
  const { progress, start, cancel } = useDownload(initial)
  const { expand, minimize } = useIsland()

  React.useEffect(() => {
    if (view === "expanded") expand()
    else if (view === "minimal") minimize()
  }, [view, expand, minimize])

  return (
    <View className="flex-1 items-center justify-center gap-4">
      <DownloadIsland progress={progress} onCancel={cancel} />
      <Text className="max-w-xs text-center text-sm text-muted-foreground">
        Start a download, then tap the pill at the top to see the details. Swipe
        up on it to close.
      </Text>
      {initial === null ? (
        <Button onPress={start} disabled={progress !== null}>
          <Text>
            {progress === null ? "Download Design-Kit.zip" : "Downloading…"}
          </Text>
        </Button>
      ) : null}
      {withNotifications ? (
        <NotifyButtons>
          <Button
            variant="secondary"
            disabled={progress === null}
            onPress={minimize}
          >
            <Text>Minimize</Text>
          </Button>
        </NotifyButtons>
      ) : null}
    </View>
  )
}

export function IslandMinimalExample() {
  return (
    <IslandProvider>
      <DownloadsExample initial={0.42} view="minimal" />
    </IslandProvider>
  )
}
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.bold.DownloadSimple
import com.adamglin.phosphoricons.fill.Bell
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.CheckCircle
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.Island
import com.esuiss.ui.interactions.IslandProvider
import com.esuiss.ui.interactions.IslandRegion
import com.esuiss.ui.interactions.IslandRing
import com.esuiss.ui.interactions.useIsland
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlin.math.roundToInt

@Composable
fun IslandMinimalExample() {
    IslandProvider { IslandDownloads(initial = 0.42f, start = IslandStart.Minimal) }
}

enum class IslandStart { Compact, Expanded, Minimal }

@Composable
fun IslandNotifyButtons(extra: @Composable () -> Unit = {}) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    FlowRow(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Button(
            "New message",
            onClick = {
                island.notify(
                    title = "Jordan Lee",
                    description = "Are we still on for Saturday?",
                    icon = { Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, tint = Palette.green500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        Button(
            "Reminder",
            onClick = {
                island.notify(
                    title = "Design review",
                    description = "Starts in 10 minutes",
                    icon = { Icon(PhosphorIcons.Fill.Bell, contentDescription = null, tint = Palette.orange500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        extra()
    }
}

@Composable
fun IslandProgress(progress: Float) {
    val colors = EsuissTheme.colors
    if (progress >= 1f) {
        Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500)
    } else {
        IslandRing(progress)
    }
}

@Composable
fun IslandDownloadIsland(progress: Float?, onCancel: () -> Unit) {
    val colors = EsuissTheme.colors
    val current = progress ?: 0f
    val done = current >= 1f
    val percent = (current * 100).roundToInt()
    Island(
        compactLeading = progress?.let {
            { Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, tint = colors.primary) }
        },
        compactTrailing = progress?.let { value -> { IslandProgress(value) } },
        minimal = progress?.let { value -> { IslandProgress(value) } },
        modifier = Modifier.semantics { contentDescription = "Downloading Design-Kit.zip, $percent%" },
    ) {
        IslandExpandedRegion(IslandRegion.Leading) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .background(foreground.copy(alpha = 0.15f), RoundedCornerShape(Radius.xl)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, size = 24.dp, tint = colors.primary)
            }
        }
        IslandExpandedRegion(IslandRegion.Trailing) {
            Text("$percent%", style = Typography.xl2.copy(color = LocalContentColor.current, fontWeight = FontWeight.SemiBold))
        }
        IslandExpandedRegion(IslandRegion.Center) {
            val foreground = LocalContentColor.current
            Text(
                if (done) "Downloaded" else "Downloading",
                style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            Text(
                if (done) "Design-Kit.zip" else "Design-Kit.zip · ${(current * IslandSize).roundToInt()} of $IslandSize MB",
                style = Typography.sm.copy(color = foreground.copy(alpha = 0.7f)),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        IslandExpandedRegion(IslandRegion.Bottom) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(6.dp)
                    .clip(CircleShape)
                    .background(foreground.copy(alpha = 0.2f))
                    .semantics {
                        contentDescription = "Download progress"
                        progressBarRangeInfo = ProgressBarRangeInfo(percent.toFloat(), 0f..100f)
                    },
            ) {
                Box(
                    Modifier
                        .fillMaxWidth(current.coerceIn(0f, 1f))
                        .fillMaxHeight()
                        .background(colors.primary, CircleShape),
                )
            }
            Spacer(Modifier.height(12.dp))
            val interaction = remember { MutableInteractionSource() }
            val pressed by interaction.collectIsPressedAsState()
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(44.dp)
                    .alpha(if (done) 0.5f else 1f)
                    .clip(CircleShape)
                    .alpha(if (pressed) 0.8f else 1f)
                    .background(foreground.copy(alpha = 0.15f))
                    .clickable(interactionSource = interaction, indication = null, enabled = !done, role = Role.Button, onClick = onCancel),
                contentAlignment = Alignment.Center,
            ) {
                Text("Cancel download", style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold))
            }
        }
    }
}

@Composable
fun IslandDownloads(initial: Float? = null, start: IslandStart = IslandStart.Compact, withNotifications: Boolean = false) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    var progress by rememberSaveable { mutableStateOf(initial) }
    LaunchedEffect(progress) {
        val current = progress ?: return@LaunchedEffect
        if (initial != null) return@LaunchedEffect
        if (current < 1f) {
            delay(150)
            progress = minOf(current + 0.01f, 1f)
        } else {
            delay(1200)
            progress = null
            island.notify(
                title = "Download complete",
                description = "Design-Kit.zip",
                icon = { Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500) },
            )
        }
    }
    LaunchedEffect(start) {
        when (start) {
            IslandStart.Expanded -> island.expand()
            IslandStart.Minimal -> island.minimize()
            IslandStart.Compact -> Unit
        }
    }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        IslandDownloadIsland(progress) { progress = null }
        Text(
            "Start a download, then tap the pill at the top to see the details. Swipe up on it to close.",
            modifier = Modifier.widthIn(max = 320.dp),
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
        if (initial == null) {
            Button(
                if (progress == null) "Download Design-Kit.zip" else "Downloading…",
                onClick = { progress = 0f },
                enabled = progress == null,
            )
        }
        if (withNotifications) {
            IslandNotifyButtons {
                Button(
                    "Minimize",
                    onClick = island::minimize,
                    variant = ButtonVariant.Secondary,
                    enabled = progress != null,
                )
            }
        }
    }
}

Notice

Shows a notice on mount.

iOS
Island (notice) on iOS in light modeIsland (notice) on iOS in dark mode
import { BellIcon, ChatCircleIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Island, IslandProvider, useIsland } from "@/components/interactions/island"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

function NotifyButtons({ children }: { children?: React.ReactNode }) {
  const { notify } = useIsland()

  return (
    <View className="flex-row flex-wrap justify-center gap-3">
      {children}
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon
                as={ChatCircleIcon}
                weight="fill"
                className="text-green-500"
              />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        <Text>New message</Text>
      </Button>
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon as={BellIcon} weight="fill" className="text-orange-500" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        <Text>Reminder</Text>
      </Button>
    </View>
  )
}

function NoticeOnMount() {
  const { notify } = useIsland()
  const shownRef = React.useRef(false)

  React.useEffect(() => {
    if (shownRef.current) return
    shownRef.current = true
    notify({
      icon: (
        <Icon as={ChatCircleIcon} weight="fill" className="text-green-500" />
      ),
      title: "Jordan Lee",
      description: "Are we still on for Saturday?",
      duration: 600000,
    })
  }, [notify])

  return (
    <View className="flex-1 items-center justify-center gap-4">
      <Island />
      <Text className="max-w-xs text-center text-sm text-muted-foreground">
        The pill grows to show the notification and shrinks back on its own.
      </Text>
      <NotifyButtons />
    </View>
  )
}

export function IslandNoticeExample() {
  return (
    <IslandProvider>
      <NoticeOnMount />
    </IslandProvider>
  )
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.Bell
import com.adamglin.phosphoricons.fill.ChatCircle
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.Island
import com.esuiss.ui.interactions.IslandProvider
import com.esuiss.ui.interactions.useIsland
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography

@Composable
fun IslandNoticeExample() {
    IslandProvider { IslandNoticeOnMount() }
}

@Composable
fun IslandNotifyButtons(extra: @Composable () -> Unit = {}) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    FlowRow(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Button(
            "New message",
            onClick = {
                island.notify(
                    title = "Jordan Lee",
                    description = "Are we still on for Saturday?",
                    icon = { Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, tint = Palette.green500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        Button(
            "Reminder",
            onClick = {
                island.notify(
                    title = "Design review",
                    description = "Starts in 10 minutes",
                    icon = { Icon(PhosphorIcons.Fill.Bell, contentDescription = null, tint = Palette.orange500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        extra()
    }
}

@Composable
fun IslandNoticeOnMount() {
    val island = useIsland()
    val colors = EsuissTheme.colors
    var shown by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (shown) return@LaunchedEffect
        shown = true
        island.notify(
            title = "Jordan Lee",
            description = "Are we still on for Saturday?",
            duration = 600000L,
            icon = { Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, tint = Palette.green500) },
        )
    }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Island()
        Text(
            "The pill grows to show the notification and shrinks back on its own.",
            modifier = Modifier.widthIn(max = 320.dp),
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
        IslandNotifyButtons()
    }
}

Live activity

A download that starts on press, with buttons that minimize the island and send notices over it.

iOS
Island (live activity) on iOS in light modeIsland (live activity) on iOS in dark mode
import { BellIcon, ChatCircleIcon, CheckCircleIcon, DownloadSimpleIcon, FileZipIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { Island, IslandExpandedRegion, IslandProvider, IslandRing, useIsland } from "@/components/interactions/island"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

function NotifyButtons({ children }: { children?: React.ReactNode }) {
  const { notify } = useIsland()

  return (
    <View className="flex-row flex-wrap justify-center gap-3">
      {children}
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon
                as={ChatCircleIcon}
                weight="fill"
                className="text-green-500"
              />
            ),
            title: "Jordan Lee",
            description: "Are we still on for Saturday?",
          })
        }
      >
        <Text>New message</Text>
      </Button>
      <Button
        variant="secondary"
        onPress={() =>
          notify({
            icon: (
              <Icon as={BellIcon} weight="fill" className="text-orange-500" />
            ),
            title: "Design review",
            description: "Starts in 10 minutes",
          })
        }
      >
        <Text>Reminder</Text>
      </Button>
    </View>
  )
}

function useDownload(initial: number | null = null) {
  const [progress, setProgress] = React.useState<number | null>(initial)
  const { notify } = useIsland()

  React.useEffect(() => {
    if (initial !== null || progress === null || progress >= 1) return
    const timer = setTimeout(
      () => setProgress((value) => Math.min((value ?? 0) + 0.01, 1)),
      150
    )
    return () => clearTimeout(timer)
  }, [initial, progress])

  React.useEffect(() => {
    if (progress !== 1) return
    const timer = setTimeout(() => {
      setProgress(null)
      notify({
        icon: (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ),
        title: "Download complete",
        description: "Design-Kit.zip",
      })
    }, 1200)
    return () => clearTimeout(timer)
  }, [progress, notify])

  return {
    progress,
    start: () => setProgress(0),
    cancel: () => setProgress(null),
  }
}

function DownloadIsland({
  progress,
  onCancel,
}: {
  progress: number | null
  onCancel: () => void
}) {
  if (progress === null) return <Island />

  const done = progress >= 1
  const percent = Math.round(progress * 100)

  return (
    <Island
      accessibilityLabel={`Downloading Design-Kit.zip, ${percent}%`}
      compactLeading={
        <Icon as={DownloadSimpleIcon} weight="bold" className="text-primary" />
      }
      compactTrailing={
        done ? (
          <Icon as={CheckCircleIcon} weight="fill" className="text-green-500" />
        ) : (
          <IslandRing value={progress} />
        )
      }
      minimal={<IslandRing value={progress} />}
    >
      <IslandExpandedRegion region="leading">
        <View className="size-10 items-center justify-center rounded-xl bg-white/15">
          <Icon
            as={FileZipIcon}
            weight="fill"
            className="size-6 text-primary"
          />
        </View>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="trailing">
        <Text className="text-2xl font-semibold">{percent}%</Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="center">
        <Text numberOfLines={1} className="text-sm font-semibold">
          {done ? "Downloaded" : "Downloading"}
        </Text>
        <Text numberOfLines={1} className="text-sm text-white/70">
          Design-Kit.zip
        </Text>
      </IslandExpandedRegion>
      <IslandExpandedRegion region="bottom">
        <View
          role="progressbar"
          accessibilityLabel="Download progress"
          accessibilityValue={{ min: 0, max: 100, now: percent }}
          className="h-1.5 overflow-hidden rounded-full bg-white/20"
        >
          <View
            className="h-full rounded-full bg-primary"
            style={{ width: `${percent}%` }}
          />
        </View>
        <Pressable
          role="button"
          disabled={done}
          onPress={onCancel}
          className="h-11 items-center justify-center rounded-full bg-white/15 active:opacity-80 disabled:opacity-50"
        >
          <Text className="text-sm font-semibold">Cancel download</Text>
        </Pressable>
      </IslandExpandedRegion>
    </Island>
  )
}

function DownloadsExample({
  initial = null,
  view = "compact",
  withNotifications = false,
}: {
  initial?: number | null
  view?: "compact" | "expanded" | "minimal"
  withNotifications?: boolean
}) {
  const { progress, start, cancel } = useDownload(initial)
  const { expand, minimize } = useIsland()

  React.useEffect(() => {
    if (view === "expanded") expand()
    else if (view === "minimal") minimize()
  }, [view, expand, minimize])

  return (
    <View className="flex-1 items-center justify-center gap-4">
      <DownloadIsland progress={progress} onCancel={cancel} />
      <Text className="max-w-xs text-center text-sm text-muted-foreground">
        Start a download, then tap the pill at the top to see the details. Swipe
        up on it to close.
      </Text>
      {initial === null ? (
        <Button onPress={start} disabled={progress !== null}>
          <Text>
            {progress === null ? "Download Design-Kit.zip" : "Downloading…"}
          </Text>
        </Button>
      ) : null}
      {withNotifications ? (
        <NotifyButtons>
          <Button
            variant="secondary"
            disabled={progress === null}
            onPress={minimize}
          >
            <Text>Minimize</Text>
          </Button>
        </NotifyButtons>
      ) : null}
    </View>
  )
}

export function IslandLiveActivityExample() {
  return (
    <IslandProvider>
      <DownloadsExample withNotifications />
    </IslandProvider>
  )
}
import ActivityKit
import EsuissUI
import SwiftUI

struct IslandLiveActivity: View {
  @State private var activity: Activity<DownloadActivityAttributes>?
  @State private var state = DownloadActivityAttributes.ContentState(progress: 0, fileName: "Design-Kit.zip", finished: false)
  @State private var failure: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      Text("Start a download, then leave the app. The activity shows in the Dynamic Island and on the Lock Screen.")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
      FlowRow(spacing: 12) {
        Button("Start") { start() }
          .buttonStyle(.variant())
          .disabled(activity != nil)
        Button("Update (+10%)") { advance() }
          .buttonStyle(.variant(.secondary))
          .disabled(activity == nil || state.finished)
        Button("Finish") { finish() }
          .buttonStyle(.variant(.secondary))
          .disabled(activity == nil || state.finished)
        Button("End") { end() }
          .buttonStyle(.variant(.destructive))
          .disabled(activity == nil)
      }
      Text(verbatim: activity == nil ? "No activity" : "\(state.fileName) · \(state.percent)%")
        .textStyle(.sm, weight: .semibold)
        .monospacedDigit()
      if let failure {
        Text(verbatim: failure)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.destructive)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .task {
      activity = Activity<DownloadActivityAttributes>.activities.first
      if let current = activity?.content.state { state = current }
    }
  }

  func start() {
    guard ActivityAuthorizationInfo().areActivitiesEnabled else {
      failure = "Live Activities are turned off for this app."
      return
    }
    let next = DownloadActivityAttributes.ContentState(progress: 0, fileName: "Design-Kit.zip", finished: false)
    do {
      activity = try Activity.request(
        attributes: DownloadActivityAttributes(),
        content: ActivityContent(state: next, staleDate: nil)
      )
      state = next
      failure = nil
    } catch {
      failure = error.localizedDescription
    }
  }

  func advance() {
    state.progress = min(state.progress + 0.1, 1)
    state.finished = state.progress >= 1
    push()
  }

  func finish() {
    state.progress = 1
    state.finished = true
    push()
  }

  func push() {
    guard let id = activity?.id else { return }
    let next = state
    Task { await Self.send(next, to: id, ending: false) }
  }

  func end() {
    guard let id = activity?.id else { return }
    let next = state
    activity = nil
    Task { await Self.send(next, to: id, ending: true) }
  }

  private nonisolated static func send(_ state: DownloadActivityAttributes.ContentState, to id: String, ending: Bool) async {
    guard let activity = Activity<DownloadActivityAttributes>.activities.first(where: { $0.id == id }) else { return }
    let content = ActivityContent(state: state, staleDate: nil)
    if ending {
      await activity.end(content, dismissalPolicy: .immediate)
    } else {
      await activity.update(content)
    }
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.bold.DownloadSimple
import com.adamglin.phosphoricons.fill.Bell
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.CheckCircle
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.Island
import com.esuiss.ui.interactions.IslandProvider
import com.esuiss.ui.interactions.IslandRegion
import com.esuiss.ui.interactions.IslandRing
import com.esuiss.ui.interactions.useIsland
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlin.math.roundToInt

@Composable
fun IslandLiveActivityExample() {
    IslandProvider { IslandDownloads(withNotifications = true) }
}

enum class IslandStart { Compact, Expanded, Minimal }

@Composable
fun IslandNotifyButtons(extra: @Composable () -> Unit = {}) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    FlowRow(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Button(
            "New message",
            onClick = {
                island.notify(
                    title = "Jordan Lee",
                    description = "Are we still on for Saturday?",
                    icon = { Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, tint = Palette.green500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        Button(
            "Reminder",
            onClick = {
                island.notify(
                    title = "Design review",
                    description = "Starts in 10 minutes",
                    icon = { Icon(PhosphorIcons.Fill.Bell, contentDescription = null, tint = Palette.orange500) },
                )
            },
            variant = ButtonVariant.Secondary,
        )
        extra()
    }
}

@Composable
fun IslandProgress(progress: Float) {
    val colors = EsuissTheme.colors
    if (progress >= 1f) {
        Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500)
    } else {
        IslandRing(progress)
    }
}

@Composable
fun IslandDownloadIsland(progress: Float?, onCancel: () -> Unit) {
    val colors = EsuissTheme.colors
    val current = progress ?: 0f
    val done = current >= 1f
    val percent = (current * 100).roundToInt()
    Island(
        compactLeading = progress?.let {
            { Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, tint = colors.primary) }
        },
        compactTrailing = progress?.let { value -> { IslandProgress(value) } },
        minimal = progress?.let { value -> { IslandProgress(value) } },
        modifier = Modifier.semantics { contentDescription = "Downloading Design-Kit.zip, $percent%" },
    ) {
        IslandExpandedRegion(IslandRegion.Leading) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .background(foreground.copy(alpha = 0.15f), RoundedCornerShape(Radius.xl)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(PhosphorIcons.Bold.DownloadSimple, contentDescription = null, size = 24.dp, tint = colors.primary)
            }
        }
        IslandExpandedRegion(IslandRegion.Trailing) {
            Text("$percent%", style = Typography.xl2.copy(color = LocalContentColor.current, fontWeight = FontWeight.SemiBold))
        }
        IslandExpandedRegion(IslandRegion.Center) {
            val foreground = LocalContentColor.current
            Text(
                if (done) "Downloaded" else "Downloading",
                style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            Text(
                if (done) "Design-Kit.zip" else "Design-Kit.zip · ${(current * IslandSize).roundToInt()} of $IslandSize MB",
                style = Typography.sm.copy(color = foreground.copy(alpha = 0.7f)),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        IslandExpandedRegion(IslandRegion.Bottom) {
            val foreground = LocalContentColor.current
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(6.dp)
                    .clip(CircleShape)
                    .background(foreground.copy(alpha = 0.2f))
                    .semantics {
                        contentDescription = "Download progress"
                        progressBarRangeInfo = ProgressBarRangeInfo(percent.toFloat(), 0f..100f)
                    },
            ) {
                Box(
                    Modifier
                        .fillMaxWidth(current.coerceIn(0f, 1f))
                        .fillMaxHeight()
                        .background(colors.primary, CircleShape),
                )
            }
            Spacer(Modifier.height(12.dp))
            val interaction = remember { MutableInteractionSource() }
            val pressed by interaction.collectIsPressedAsState()
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(44.dp)
                    .alpha(if (done) 0.5f else 1f)
                    .clip(CircleShape)
                    .alpha(if (pressed) 0.8f else 1f)
                    .background(foreground.copy(alpha = 0.15f))
                    .clickable(interactionSource = interaction, indication = null, enabled = !done, role = Role.Button, onClick = onCancel),
                contentAlignment = Alignment.Center,
            ) {
                Text("Cancel download", style = Typography.sm.copy(color = foreground, fontWeight = FontWeight.SemiBold))
            }
        }
    }
}

@Composable
fun IslandDownloads(initial: Float? = null, start: IslandStart = IslandStart.Compact, withNotifications: Boolean = false) {
    val island = useIsland()
    val colors = EsuissTheme.colors
    var progress by rememberSaveable { mutableStateOf(initial) }
    LaunchedEffect(progress) {
        val current = progress ?: return@LaunchedEffect
        if (initial != null) return@LaunchedEffect
        if (current < 1f) {
            delay(150)
            progress = minOf(current + 0.01f, 1f)
        } else {
            delay(1200)
            progress = null
            island.notify(
                title = "Download complete",
                description = "Design-Kit.zip",
                icon = { Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, tint = Palette.green500) },
            )
        }
    }
    LaunchedEffect(start) {
        when (start) {
            IslandStart.Expanded -> island.expand()
            IslandStart.Minimal -> island.minimize()
            IslandStart.Compact -> Unit
        }
    }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        IslandDownloadIsland(progress) { progress = null }
        Text(
            "Start a download, then tap the pill at the top to see the details. Swipe up on it to close.",
            modifier = Modifier.widthIn(max = 320.dp),
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
        if (initial == null) {
            Button(
                if (progress == null) "Download Design-Kit.zip" else "Downloading…",
                onClick = { progress = 0f },
                enabled = progress == null,
            )
        }
        if (withNotifications) {
            IslandNotifyButtons {
                Button(
                    "Minimize",
                    onClick = island::minimize,
                    variant = ButtonVariant.Secondary,
                    enabled = progress != null,
                )
            }
        }
    }
}

API Reference

01

IslandProvider

Holds the island mode, the notice and the notice timer.

PropTypeDefaultDescription
childrenReact.ReactNode-
02

IslandExpandedRegion

A region of the expanded view.

PropTypeDefaultDescription
region"leading" | "trailing" | "center" | "bottom"RequiredWhere the content sits in the expanded view.

Also accepts the props of IslandExpandedRegionProps.

03

Island

The floating pill, rendered in a portal, with slots for the compact and minimal views.

PropTypeDefaultDescription
classNamestring-
compactLeadingReact.ReactNode-Content at the start of the compact pill.
compactTrailingReact.ReactNode-Content at the end of the compact pill.
minimalReact.ReactNode-Content of the minimal view.
portalHoststring-Name of a portal host to render into.

Also accepts the props of React.ComponentProps<typeof View>.

04

IslandRing

A small circular progress ring.

PropTypeDefaultDescription
valuenumberRequiredProgress from 0 to 1.
classNamestring-

Accessibility

  • Notices are announced with their title and description when they are text.
  • The compact and minimal views are buttons that expose expanded and have activate and expand actions.
  • The expanded view has the group role, a collapse action and closes on the escape gesture.
  • The notice is a button with activate and dismiss actions.
  • Inactive views and the progress ring are hidden from screen readers.

Notes

  • The island uses bg-black with text-white.
  • Expanded and notice views are up to 384 points wide.
  • Size and fade animations respect the system reduce motion setting.
NextOTP AutofillPreviousImage Viewer

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Live activity
    • Activity with notifications
  4. 04API Reference
    • IslandProvider
    • Island
    • IslandCompact
    • IslandExpanded
    • IslandRing
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Expanded
    • Minimal
    • Notice
    • Live activity
  10. 10API Reference
    • IslandProvider
    • IslandExpandedRegion
    • Island
    • IslandRing
  11. 11Accessibility
  12. 12Notes