Island actions
NewThe black pill at the top of a phone, used by an assistant to report live actions: it buds off a drop, glows red, opens to a deal at risk and walks a follow-up through to done.
island-actions/island-actions.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- An assistant on a phone that works in the background and has to report a short task: needs you, working, ready for review, done.
- A flow of two or three taps that should never leave the current screen: open, send, approve.
- A live status that changes shape with its weight: a small pill while it works, a wide card when it needs a decision.
- A brand moment for an agent’s actions on mobile, where the morphing black shape is the product’s signature.
When not to use
- Anything to read at length or edit. Open a sheet or a full screen; the island holds one line and one action.
- Several tasks at once. It shows one story at a time; use a notification list or an inbox.
- Desktop or a wide layout. It is drawn as the top of a 390-point phone; use a toast or an approval card.
- Errors that block the person. Use an alert dialog with a clear way out, not a pill that folds itself away.
- Plain system notifications with no follow-up. Use a toast; the island’s steps exist for a decision.
You can add this component with the shadcn CLI or by hand:
Into components/island-actions of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/island-actions.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { IslandActions, type IslandState } from './island-actions';import { useState } from 'react';export function AssistantIsland() { const [state, setState] = useState<IslandState>('alert'); return ( <IslandActions state={state} onStateChange={setState} assistantName="Tuli" dealName="Halden" actionCount={4} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A phone-top island for an assistant at work. The pill buds off a gooey drop that lights up as the assistant’s mark and is swallowed back into a red alert. Tap it: a deal is at risk; open it and a reply is drafted; send it and the island spins while it writes, glows blue for review, turns green once approved, reports four actions done, lists the apps it used and folds back into the plain pill. Every state is a prop, and every button works.
- The phone is drawn in CSS at its design size, a 390 × 844 screen, and scaled as one piece from the stage’s own size (a size container; tan(atan2()) of two lengths is their ratio), so its top fills four fifths of the stage, close up on the island.
- The black is two boxes, the pill and a drop, in one layer under an SVG filter: a Gaussian blur, then a colour matrix that turns alpha into a hard threshold. Near each other their blurred edges add up past the threshold, so they stretch a neck and melt together; apart they are two clean shapes. The mark, words and buttons sit above, outside the filter.
- Every part is placed in design px from the island’s top middle, per state, and moves there by CSS transitions whose curve is a damped spring sampled into linear(): stiffness 380 and damping 22 for the pill (about 11% past, settled in 0.55 s), a softer 150 / 15 for the drop leaving and coming back.
- Leaving the plain pill runs through two frames before the target: the drop leaves (1 s), the mark lights up in it (0.5 s), then it is swallowed. Coming home is the reverse: the mark pulls out to the left, goes dark, and the drop melts back in.
- Words come in one by one, 70 ms apart, out of a 6 px blur, once the pill has made room; badges pop from 60% scale in order. The spinner’s arc grows from 6% to 86% of the ring as it turns, then closes into a ring and a tick is drawn in.
- With `state`, the island shows that state and only reports taps through onStateChange. Without it, it runs itself (the reply is written in 1.7 s, an approval opens the report, "All done" folds away after a second), and while on screen and untouched a demo finger plays the whole story in a 14 s loop. Any input hands it to the reader.
| Prop | Type | Default | Description |
|---|---|---|---|
state | 'idle' | 'alert' | 'risk' | 'followUp' | 'working' | 'review' | 'done' | 'complete' | 'allDone' | — | Shows this state and leaves every change to you: taps only call onStateChange, nothing moves on by itself and the demo is off. Changing it animates there. |
defaultState | IslandState | 'idle' | Where an island that runs itself starts. |
onStateChange | (state: IslandState) => void | — | Called with the state a tap asks for, and, when the island runs itself, with each state it moves on to. |
assistantName | string | 'Tuli' | The assistant’s name, in “Tuli completed 4 actions”. |
dealName | string | 'Halden' | The deal’s name, in “Halden deal at risk!”. |
actionCount | number | 4 | How many actions the report counts, in its green badge and its words. |
autoplay | boolean | true | While visible and untouched, a demo finger plays the story in a 14 s loop (not with `state`). Any input from the reader stops it. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Added to the stage element. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode,} from 'react';import { Bang, CalendarMark, ChatMark, DealMark, DotsMark, MailMark, OpenMark, Pencil, Tick,// Copyright (c) 2026 Bruno Delorence. MIT License./* * The island's small drawings, all decorative (the buttons around them carry the names). Each SVG * is sized by its class in the CSS, never by attributes, so the lab's 16 px rule for unsized SVGs * never reaches them. *//** The assistant's mark: three white dots of three sizes, on the red of the glyph behind them. */export function DotsMark({ className }: { className: string }) { return ( <svg className={className} viewBox="0 0 30 22" aria-hidden="true" focusable="false"> <circle cx="7.2" cy="8.4" r="5.4" /> <circle cx="23.4" cy="7.6" r="4.4" /> <circle cx="15.6" cy="16.6" r="3.1" /> </svg> );}/** An exclamation mark: a tapered bar and a dot. */export function Bang({ className }: { className: string }) { return ( <svg className={className} viewBox="0 0 8 24" aria-hidden="true" focusable="false"> <path d="M1.4 2.6a2.6 2.6 0 0 1 5.2 0L5.6 15.2a1.6 1.6 0 0 1-3.2 0Z" />// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` alone says whether looping art * (a pulse, a spinner) is worth running at all. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden);/* Island actions (island-actions.tsx): the top of a phone on a quiet grey stage, close up, with the island at its top. The phone is drawn at its design size (a 390 × 844 screen) and scaled as one piece from the stage's own size (a size container; tan(atan2()) of two lengths is their ratio as a plain number), so its top fills about four fifths of the stage's width. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The island and what is drawn on it are the same on both: black hardware, lit by the assistant's red, blue and green. Memory. The stage is one layout boundary sized from outside. The island's shapes are a handful of small absolutely placed boxes inside it; the springs (linear() curves made in the TSX) move them, and the words and badges come and go by opacity, blur and scale. */.root { /* The stage, the phone's glass and its rim. */ --stage: #dbdbdb; --glass-1: #eaeaea; --glass-2: #e2e2e2; --rim: rgb(255 255 255 / 0.95); --shade: rgb(0 0 0 / 0.04); /* The status bar's words and the home indicator. */ --status-ink: #0b0b0b; /* The same on both schemes: the island and its lights. */- The island is a real button: its name says what the assistant is reporting, aria-expanded says whether it is open, and a press opens or closes it. Escape folds it back to the plain pill.
- Every control on it (open the deal, send the follow-up, edit the draft, approve, show what was done, dismiss) is a button with its own name and a visible :focus-visible ring; hidden ones are inert, so neither the keyboard nor a screen reader reaches them.
- A live region announces each new state: the alert, the deal at risk, the draft being written and ready, the approval and the report.
- The phone, status bar, mark, badges, goo shapes and demo finger are decorative and hidden from assistive technology.
- Under reduced motion the island changes state in one step, with no drop, blur, spring, pulse or spinning, and the demo never plays. The pulse and the spinner also pause while the island is off screen or the tab is hidden.