Island Toast
A notice that drops from the top as a pill, grows with the message and goes back up.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { SpringButton } from "../spring-button/SpringButton";
import { IslandToast } from "./IslandToast";
const messages = ["Order sent", "Payment confirmed", "Customer notified"];
const check = (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12l5 5 9-10" />
</svg>
);
export default function Demo() {
const [message, setMessage] = useState<string | null>(null);
const [turn, setTurn] = useState(0);
const send = () => {
setMessage(messages[turn % messages.length]);
setTurn((t) => t + 1);
};
useAutoplay(() => (message ? setMessage(null) : send()), 1300);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 16, width: "min(100%, 360px)" }}>
<IslandToast message={message} icon={check} />
<SpringButton variant="quiet" onClick={() => (message ? setMessage(null) : send())}>
{message ? "Close notice" : "Send notice"}
</SpringButton>
</div>
);
}Installation
npx @usefluid/cli add island-toastpnpm dlx @usefluid/cli add island-toastyarn dlx @usefluid/cli add island-toastbunx @usefluid/cli add island-toastInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/island-toast/IslandToast.tsx "use client"; import { useSpring } from "@usefluid/motion/react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import styles from "./IslandToast.module.css"; const BODY = { w: 16, z: 0.8 }; const DROP = { w: 18, z: 0.72 }; const CHECK = ( <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"> <path d="M5 12.5l4.5 4.5L19 7.5" /> </svg> ); type Props = { message: string | null; icon?: ReactNode; }; export function IslandToast({ message, icon = CHECK }: Props) { const body = useRef<HTMLDivElement>(null); const [shown, setShown] = useState<string | null>(message); const measure = useRef<HTMLSpanElement>(null); const [width, setWidth] = useState(0); useEffect(() => { if (message) setShown(message); }, [message]); useEffect(() => { if (measure.current) setWidth(measure.current.offsetWidth); }, [shown]); const open = Boolean(message); useSpring( { w: open ? Math.max(120, width) : 40, h: open ? 52 : 36 }, ({ w, h }) => { if (!body.current) return; body.current.style.width = `${w}px`; body.current.style.height = `${h}px`; }, BODY, ); useSpring( { y: open ? 0 : -72 }, ({ y }) => { if (body.current) body.current.style.translate = `0 ${y}px`; }, DROP, ); return ( <div className={styles.slot}> <span ref={measure} className={styles.measure} aria-hidden> <span className={styles.row}>{icon && <span className={styles.icon}>{icon}</span>}{shown}</span> </span> <div ref={body} className={styles.island} data-open={open}> <span className={styles.row} role="status"> {icon && <span className={styles.icon} aria-hidden>{icon}</span>} {open ? shown : ""} </span> </div> </div> ); }components/fluid/island-toast/IslandToast.module.css .slot { position: relative; display: flex; justify-content: center; width: 100%; height: 64px; overflow: hidden; } .island { display: grid; place-items: center; max-width: 100%; overflow: hidden; border-radius: 999px; background: var(--fluid-accent); color: var(--fluid-on-accent); font: 500 15px/1 var(--fluid-font); white-space: nowrap; } .row { display: inline-flex; align-items: center; gap: 10px; padding: 0 20px 0 16px; transition: opacity 180ms ease, filter 180ms ease; } .island[data-open="false"] .row { opacity: 0; filter: blur(4px); } .island[data-open="true"] .row { transition-delay: 140ms; } .icon { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 999px; background: var(--fluid-on-accent); color: var(--fluid-accent); } .measure { position: absolute; visibility: hidden; pointer-events: none; font: 500 15px/1 var(--fluid-font); white-space: nowrap; } @media (prefers-reduced-motion: reduce) { .slot, .slot * { transition: none !important; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { IslandToast } from "./IslandToast";
<IslandToast message={notice} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
message | string | null | — | Set a text to show it; null hides it. You decide when. |
icon | ReactNode | a check | Any icon; null for none. |
Keyboard and screen readers
A status region: the message is announced.
Notes
Place it once near the root, in a fixed layer.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
The drop, the width and the height are three springs, so the pill arrives, stretches to fit the sentence and leaves as one body. The width comes from the real size of the text. The sentence comes in once there is room for it, and goes to screen readers through a status region.
For your agent
For your agent
Add Island Toast to my project with the Fluid library. Use IslandToast for the result notices of my product's actions. 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.