Morph Island
A pill that grows into a panel, in the same body.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { MorphIsland } from "./MorphIsland";
export default function Demo() {
const [open, setOpen] = useState(false);
useAutoplay(() => setOpen((o) => !o), 1800);
return (
<MorphIsland label="New order" width={270} height={150} open={open} onOpenChange={setOpen}>
Order 214 is ready to go. Let the customer know now?
</MorphIsland>
);
}Installation
npx @usefluid/cli add morph-islandpnpm dlx @usefluid/cli add morph-islandyarn dlx @usefluid/cli add morph-islandbunx @usefluid/cli add morph-islandInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/morph-island/MorphIsland.tsx "use client"; import { useSpring } from "@usefluid/motion/react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import styles from "./MorphIsland.module.css"; const BODY = { w: 17, z: 0.8 }; type Props = { label: string; closeLabel?: string; children: ReactNode; width?: number; height?: number; closedWidth?: number; open?: boolean; onOpenChange?(open: boolean): void; }; export function MorphIsland({ label, closeLabel = "Close", children, width = 300, height = 170, closedWidth = 150, open: controlled, onOpenChange, }: Props) { const [own, setOwn] = useState(false); const open = controlled ?? own; const body = useRef<HTMLDivElement>(null); const trigger = useRef<HTMLButtonElement>(null); const close = useRef<HTMLButtonElement>(null); const byPerson = useRef(false); const set = (next: boolean) => { byPerson.current = true; setOwn(next); onOpenChange?.(next); }; useSpring( { w: open ? width : closedWidth, h: open ? height : 44 }, ({ w, h }) => { if (!body.current) return; body.current.style.width = `${w}px`; body.current.style.height = `${h}px`; }, BODY, ); useEffect(() => { if (!byPerson.current) return; byPerson.current = false; if (open) close.current?.focus(); else trigger.current?.focus(); }, [open]); return ( <div ref={body} className={styles.body} data-open={open} onKeyDown={(e) => { if (e.key === "Escape") set(false); }}> <button ref={trigger} type="button" aria-expanded={open} className={styles.trigger} inert={open} onClick={() => set(true)}> {label} </button> <div className={styles.panel} inert={!open} style={{ width: `min(${width}px, 100cqi)` }}> <div className={styles.content}>{children}</div> <button ref={close} type="button" className={styles.close} onClick={() => set(false)}> {closeLabel} </button> </div> </div> ); }components/fluid/morph-island/MorphIsland.module.css .body { position: relative; container-type: inline-size; max-width: 100%; overflow: hidden; border-radius: 24px; background: var(--fluid-accent); color: var(--fluid-on-accent); font-family: var(--fluid-font); } .trigger { position: absolute; inset: 0; width: 100%; border: 0; background: none; color: inherit; font: 500 15px/1 var(--fluid-font); white-space: nowrap; cursor: pointer; transition: opacity 140ms ease; } .trigger:focus-visible { outline: 2px solid var(--fluid-on-accent); outline-offset: -4px; border-radius: 24px; } .body[data-open="true"] .trigger { opacity: 0; pointer-events: none; } .panel { position: absolute; top: 0; left: 0; display: flex; flex-direction: column; justify-content: space-between; gap: 8px; height: 100%; padding: 18px 18px 8px; opacity: 0; transform: translateY(6px); transition: opacity 120ms ease, transform 200ms ease; } .body[data-open="true"] .panel { opacity: 1; transform: none; transition-delay: 120ms; } .content { font-size: 15px; line-height: 1.4; } .close { align-self: flex-end; min-height: 44px; min-width: 44px; padding: 0 10px; border: 0; border-radius: 12px; background: none; color: inherit; font: 500 14px/1 var(--fluid-font); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } .close:focus-visible { outline: 2px solid var(--fluid-on-accent); outline-offset: -2px; }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { MorphIsland } from "./MorphIsland";
<MorphIsland label="Quick add" width={300} height={170}>
<form>...</form>
</MorphIsland>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The pill's text and the button's name. |
children | ReactNode | — | The panel's content. |
width, height | number | 300, 170 | Open size in px. |
closedWidth | number | 150 | Closed size in px. |
open, onOpenChange | — | Control it from outside, or leave it alone. | |
closeLabel | string | "Close" |
Keyboard and screen readers
Esc closes it and gives focus back to the pill.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
Width and height are two springs with the same damping, so the pill grows as one body. The content comes in after 120 ms, once there is room for it. Focus moves into the panel on open and back to the pill on close, and Esc closes it.
For your agent
For your agent
Add Morph Island to my project with the Fluid library. Use MorphIsland for the main quick action, with the content that makes sense in my product. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.