Iris
React 19motion ≥12A draggable trigger that morphs into a modal sheet. It rests at one of seven viewport anchors, can be dragged and re-anchored, and grows into the sheet on one spring. You supply all content; the package owns the morph.A draggable trigger that morphs into a modal sheet. You supply all content; the package owns the morph.
Playground
Pick a shape, recipe, props and colour tokens, then copy the JSX and CSS that reproduce the specimen.On a phone the controls open in a sheet, itself an Iris.
Installation
Install the package and its peer dependencies. Styles are bundled and imported for you.
npm install @orrery-ui/iris
npm install react react-dom motion@14Prefer to own the files? npx @orrery-ui/iris add copies the source into ./src/orrery-iris.
npx @orrery-ui/iris add tuner copies the motion-tuning panel instead (it needs dialkit as a dev dependency).
Or copy the source with npx @orrery-ui/iris add, and the motion tuner with npx @orrery-ui/iris add tuner.
Usage
Paste this into app/page.tsx in a Next.js App Router app, or App.tsx in Vite. It renders a trigger at the bottom-center anchor: drag it anywhere, tap it to open the sheet.Paste into app/page.tsx (Next.js) or App.tsx (Vite).
"use client";
import { Iris } from "@orrery-ui/iris";
export default function ContactTrigger() {
return (
<Iris.Root>
<Iris.Shadow />
<Iris.Trigger aria-label="Open contact">
<Iris.Shared>
<div style={{ width: "100%", height: "100%", background: "#1d1d1f" }} />
</Iris.Shared>
</Iris.Trigger>
<Iris.Sheet aria-labelledby="sheet-title">
<Iris.Shared>
<div style={{ width: "100%", height: "100%", background: "#1d1d1f" }} />
</Iris.Shared>
<Iris.Close aria-label="Close" />
<Iris.Content>
<Iris.Item>
<h2 id="sheet-title">Sean Smith</h2>
</Iris.Item>
<Iris.Item>
<p>Links, etc.</p>
</Iris.Item>
</Iris.Content>
</Iris.Sheet>
</Iris.Root>
);
}"use client" has to be the first line of the file that mounts Iris. Without it, React reports a generic “Element type is invalid” error that never names this package.
Examples
Live example pages built on Iris, each with its source.


Contact form
A real form inside the sheet, focus kept in.


Media card
The tapped icon grows into the card’s own icon.


Link preview
A hover card on a text link; press and hold on touch.


List
A menu: each row staggers in on its own.


Buttons
Rectangle triggers in set sizes, like a search button.


Video
A video that plays inside the morph.


Flagship
A portrait that morphs into a contact card.
API reference
The Iris namespace holds nine components: Root, Trigger, Sheet, Shared, Media, Content, Item, Close and Shadow. The package also exports the useIris() hook and presets.The most used Iris.Root props:
Iris.Root
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultOpen | boolean | false | Uncontrolled initial state. |
| open | boolean | — | Controlled. When set, the package never sets open itself. |
| onOpenChange | (open) => void | — | Fires on every requested state change. |
| defaultAnchor | AnchorId | "bottom-center" | Uncontrolled initial anchor, one of seven. |
| onAnchorChange | (anchor) => void | — | Fires once per move: drag release, arrow key or setAnchor. |
| draggable | boolean | true | False renders a fixed trigger with no drag affordance. |
| persistKey | string | false | "orrery-iris-anchor" | localStorage key for the chosen anchor. False turns it off. |
| triggerSize | number | { base, md, xl } | 96 / 128 / 144 | Trigger diameter in px, or a breakpoint ramp. |
| sheetMaxWidth | number | 480 | Sheet max width in px. 360 for a link preview. |
| shape | TriggerShape | "circle" | circle, squircle, rounded-square, square or rectangle. |
| buttonSize | "s" | "m" | "l" | "m" | Height and padding of a rectangle trigger. |
| preset | MotionPreset | presets.default | A named feel: default, snappy or gentle. |
| transition | MorphTransition | — | open, close and shared springs. Wins over preset. |
| reduceMotion | boolean | media query | Force the reduced-motion crossfade. |
| preview | boolean | false | Turns a text link into a hover-card preview. |
Also on Root: anchorAnnouncement, buttonWidth, id, zIndex, className.
- open—
boolean
Controlled. The package never sets it itself.
- onOpenChange—
(open) => void
Fires on every requested state change.
- defaultAnchor"bottom-center"
AnchorId
Initial anchor, one of seven.
- shape"circle"
TriggerShape
circle, squircle, rounded-square, square, rectangle.
- presetpresets.default
MotionPreset
default, snappy or gentle.
- reduceMotionmedia query
boolean
Force the reduced-motion crossfade.
Show all 21 Root props
- defaultOpenfalse
boolean
Uncontrolled initial state.
- onAnchorChange—
(anchor) => void
Fires once per move: drag release, arrow key or setAnchor.
- draggabletrue
boolean
False renders a fixed trigger with no drag affordance.
- persistKey"orrery-iris-anchor"
string | false
localStorage key for the chosen anchor. False turns it off.
- triggerSize96 / 128 / 144
number | { base, md, xl }
Trigger diameter in px, or a breakpoint ramp.
- sheetMaxWidth480
number
Sheet max width in px. 360 for a link preview.
- buttonSize"m"
"s" | "m" | "l"
Height and padding of a rectangle trigger.
- transition—
MorphTransition
open, close and shared springs. Wins over preset.
- previewfalse
boolean
Turns a text link into a hover-card preview.
Also: anchorAnnouncement, buttonWidth, id, zIndex, className, children.
Iris.Sheet
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label / -labelledby | string | required | A missing accessible name is a type error. |
| dismissOnSwipe | boolean | true | Drag the sheet down past threshold to close. |
| dismissOnBackdrop | boolean | true | Click outside the sheet to close. |
| aspectRatio | number | — | Width / height. Contain-fits inside sheetMaxWidth. |
| initialFocus | RefObject | panel | Control to focus once the open settles. |
Iris.Sheet · 5 props
- aria-label / -labelledbyrequired
string
A missing accessible name is a type error.
- dismissOnSwipetrue
boolean
Drag the sheet down past threshold to close.
- dismissOnBackdroptrue
boolean
Click outside the sheet to close.
- aspectRatio—
number
Width / height. Contain-fits inside sheetMaxWidth.
- initialFocuspanel
RefObject
Control to focus once the open settles.
Iris.Media
Video or a still that scales through the morph without squashing. Use it in place of Iris.Shared, in both the Trigger and the Sheet, and give the Sheet the same aspectRatio. The trigger rests on the poster; the sheet’s video plays muted and looped.
| Prop | Type | Default | Description |
|---|---|---|---|
| poster | string | required | The first frame. What reduced-motion users see, paused. |
| aspectRatio | number | required | Width / height. Sizes the media before it loads, so it never squashes. |
| src | string | — | The video. Omit it for a still image. |
| alt | string | — | Makes the media content instead of decoration. |
- posterrequired
string
The first frame. What reduced-motion users see, paused.
- aspectRatiorequired
number
Width / height. Sizes the media before it loads, so it never squashes.
- src—
string
The video. Omit it for a still image.
- alt—
string
Makes the media content instead of decoration.
const ratio = 9 / 16;
<Iris.Root>
<Iris.Shadow />
<Iris.Trigger aria-label="Play intro">
<Iris.Media src="/intro.mp4" poster="/intro.jpg" aspectRatio={ratio} />
</Iris.Trigger>
<Iris.Sheet aria-label="Intro video" aspectRatio={ratio}>
<Iris.Media src="/intro.mp4" poster="/intro.jpg" aspectRatio={ratio} />
<Iris.Close aria-label="Close" />
</Iris.Sheet>
</Iris.Root>Link preview
<Iris.Root preview> turns a text link into a hover card holding a live iframe of the page it points at, for pages on sites you own. The card opens after the pointer rests on the link, or on a press and hold on touch; the link stays an ordinary link.
"use client";
import { Iris, useIris } from "@orrery-ui/iris";
export function LinkPreview({ href, children }: { href: string; children: string }) {
return (
<Iris.Root preview>
<Iris.Shadow />
<Iris.Trigger asChild>
<a href={href}>{children}</a>
</Iris.Trigger>
<Iris.Sheet aria-label={`Preview of ${children}`} aspectRatio={360 / 520}>
<Iris.Content>
<PreviewFrame href={href} />
</Iris.Content>
</Iris.Sheet>
</Iris.Root>
);
}
function PreviewFrame({ href }: { href: string }) {
const { open } = useIris();
if (!open) return null;
return <iframe src={href} title={`Preview of ${href}`} tabIndex={-1} />;
}Theming
Every visual token is a --orrery-iris-* custom property with a fallback, so Iris renders correctly with no setup. Override any of them on the Root’s className.Every visual token is a --orrery-iris-* custom property with a fallback.
- --orrery-iris-surface#fafafaTrigger
- --orrery-iris-surface-elevated#ffffffSheet
- --orrery-iris-surface-borderrgba(229,229,229,.6)1px ring
- --orrery-iris-text#1d1d1fText
- --orrery-iris-accent#1d1d1fFocus ring only
- --orrery-iris-sheet-radius48pxSheet corners
- --orrery-iris-trigger-radius9999pxCaps every shape
- --orrery-iris-sheet-padding24pxSheet inset
- --orrery-iris-sheet-max-width480pxSheet width, set by sheetMaxWidth
- --orrery-iris-shadow0 2px 16px -4px …Trigger shadow
- --orrery-iris-sheet-shadow0 12px 16px -12px …Sheet shadow, same painter
Trigger shapes


"circle"circle9999px


"squircle"squirclesuperellipse


"rounded-square"rounded25%


"square"square0
shape="rectangle" is a pill button: buttonSize s, m or l sets height 36, 44 or 52px.shape="rectangle" is a pill button, buttonSize s/m/l at 36/44/52px. Dark is a README recipe; grey is a playground palette.
Palettes
Neutral
Package defaultDark
README recipeGrey
Playground palette
Keyboard & accessibility
An open sheet is a real modal: role="dialog" with aria-modal, the page goes inert, and focus returns to the trigger on close.A real modal: the page goes inert and focus returns to the trigger on close.
- EnterSpace
Opens the sheet from the focused trigger.Opens the sheet.
- Escape
Closes the top layer only: a preview card, then the sheet.Closes the top layer only.
- TabShift Tab
Moves through the sheet and wraps at either edge.Wraps inside the sheet.
- Left arrowRight arrowUp arrowDown arrow
Moves the closed trigger one anchor step. Announced politely.Moves the trigger one anchor.
Reduced motion
A 200ms opacity crossfade replaces the morph. Video stays on its poster.
Dragging alone isn’t enough
WCAG 2.5.7: render a control that calls setAnchor. A native select works.
Browser floor
Safari 16.2+, Chrome and Edge 111+, Firefox 113+.

