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/islandWorks 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 islandManual
-
Add the
cnhelper. -
Copy the source into
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- 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, anduseIslandthrow outside of it. - Render a single
Island. MountIslandCompactandIslandExpandedas its children only while an activity is running; unmounting the compact part hides the pill and collapses it. - Call
notify({ icon, title, description, duration })fromuseIslandto show a notice. It replaces any current notice and dismisses itself afterdurationmilliseconds (4000 by default). Calldismissto remove it early. useIslandalso exposesview,expanded, andsetExpandedfor 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
IslandProvider
Holds the island state and provides notify, dismiss, and the expanded state to everything inside it.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | The content that can render the island and post notifications. |
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">.
IslandCompact
The button shown in the collapsed pill; tapping it expands the island.
Also accepts the props of React.ComponentProps<"button">.
IslandExpanded
The detailed view shown when the island is expanded.
Also accepts the props of React.ComponentProps<"div">.
IslandRing
A small circular progress ring for the compact pill.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | Required | Progress from 0 to 1; values outside the range are clamped. |
Also accepts the props of React.ComponentProps<"svg">.
Accessibility
IslandCompactis a button witharia-expandedreflecting the expanded state; give it anaria-labelthat describes the activity.- Expanding moves focus to the first enabled button, link, or input inside
IslandExpanded, which hasrole="group"and isinertwhile not shown. Escapeor a pointer press outside collapses the expanded view. AfterEscape, 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.
IslandRingisaria-hidden.
Notes
- The root exposes
data-slot="island"anddata-viewwith the current view. Parts usedata-slotvaluesisland-compact,island-expanded,island-notice, andisland-ring, and carrydata-activewhile shown. - Animations are disabled under
prefers-reduced-motion.


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/islandSee 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 kotlinThe CLI also adds what it builds on: Icon, Text. On React Native it also adds the animated view components/ui/animated-view.tsx.
Manual
- 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-
Add the pieces it builds on: Icon, Text, and the primitives
components/primitives/hooks.tsx,components/primitives/portal.tsx, and the animated viewcomponents/ui/animated-view.tsxfrom the next step. -
Copy the animated view into
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) {- Copy the source into
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,- Update the import paths to match your project.
- 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-
Add the pieces it builds on: Icon, Text, and the primitives
components/primitives/hooks.tsx,components/primitives/portal.tsx, and the animated viewcomponents/ui/animated-view.tsxfrom the next step. -
Copy the animated view into
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) {- Copy the source into
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,- Update the import paths to match your project.
- 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-
Add the pieces it builds on: Icon, Text, and the primitives
components/primitives/hooks.tsx,components/primitives/portal.tsx, and the animated viewcomponents/ui/animated-view.tsxfrom the next step. -
Copy the animated view into
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) {- Copy the source into
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,- Update the import paths to match your project.
-
Target iOS 17 or later.
-
Copy the theme files every view uses:
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
}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))- Copy the source:
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
}- Add the files to your app target.
- Set
minSdkto 24 or higher and add the dependencies:
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")- Copy the theme files every view uses:
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.DpOffsetpackage 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,- Copy the source and every file it uses:
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.animateFloatAsStatepackage 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 }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.Offsetpackage 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- Update the
com.esuiss.uipackage 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.


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.


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.


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.


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
IslandProvider
Holds the island mode, the notice and the notice timer.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - |
IslandExpandedRegion
A region of the expanded view.
| Prop | Type | Default | Description |
|---|---|---|---|
region | "leading" | "trailing" | "center" | "bottom" | Required | Where the content sits in the expanded view. |
Also accepts the props of IslandExpandedRegionProps.
Island
The floating pill, rendered in a portal, with slots for the compact and minimal views.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | |
compactLeading | React.ReactNode | - | Content at the start of the compact pill. |
compactTrailing | React.ReactNode | - | Content at the end of the compact pill. |
minimal | React.ReactNode | - | Content of the minimal view. |
portalHost | string | - | Name of a portal host to render into. |
Also accepts the props of React.ComponentProps<typeof View>.
IslandRing
A small circular progress ring.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | Required | Progress from 0 to 1. |
className | string | - |
Accessibility
- Notices are announced with their title and description when they are text.
- The compact and minimal views are buttons that expose
expandedand haveactivateandexpandactions. - The expanded view has the
grouprole, acollapseaction and closes on the escape gesture. - The notice is a button with
activateanddismissactions. - Inactive views and the progress ring are hidden from screen readers.
Notes
- The island uses
bg-blackwithtext-white. - Expanded and notice views are up to 384 points wide.
- Size and fade animations respect the system reduce motion setting.