Skip to content
OrreryIris
Live, not a recording · hover a component to pause it, then drag or tap itLive · tap a component to try it

Iris

React 19motion ≥12

A 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.

/lab/orrery/playground · Basic recipeOpen full screenOpen playground
The Playground: the live Iris on its stage, with the controls beside it.The Playground: the live Iris on its stage, with the controls beside it.

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@14

Prefer 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).

app/page.tsx
"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.

  • An open sheet titled Send a message with name, email and message fields.An open sheet titled Send a message with name, email and message fields.

    Contact form

    A real form inside the sheet, focus kept in.

  • An app card for Brukas, Focus Coach, with a GET button, its price, category, age and platform, and a description.An app card for Brukas, Focus Coach, with a GET button, its price, category, age and platform, and a description.

    Media card

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

  • A hover card of phone screens opened above a link in a paragraph.A hover card of phone screens opened above a link in a paragraph.

    Link preview

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

  • An open sheet titled Quick actions listing five actions.An open sheet titled Quick actions listing five actions.

    List

    A menu: each row staggers in on its own.

  • A search sheet opened from a pill button, listing five recent items.A search sheet opened from a pill button, listing five recent items.

    Buttons

    Rectangle triggers in set sizes, like a search button.

  • A tall sheet playing a colour-bar test video.A tall sheet playing a colour-bar test video.

    Video

    A video that plays inside the morph.

  • A contact card with a portrait, an open-to-work line and contact rows.A contact card with a portrait, an open-to-work line and contact rows.

    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

Iris.Root props
PropTypeDefaultDescription
defaultOpenbooleanfalseUncontrolled initial state.
openboolean—Controlled. When set, the package never sets open itself.
onOpenChange(open) => void—Fires on every requested state change.
defaultAnchorAnchorId"bottom-center"Uncontrolled initial anchor, one of seven.
onAnchorChange(anchor) => void—Fires once per move: drag release, arrow key or setAnchor.
draggablebooleantrueFalse renders a fixed trigger with no drag affordance.
persistKeystring | false"orrery-iris-anchor"localStorage key for the chosen anchor. False turns it off.
triggerSizenumber | { base, md, xl }96 / 128 / 144Trigger diameter in px, or a breakpoint ramp.
sheetMaxWidthnumber480Sheet max width in px. 360 for a link preview.
shapeTriggerShape"circle"circle, squircle, rounded-square, square or rectangle.
buttonSize"s" | "m" | "l""m"Height and padding of a rectangle trigger.
presetMotionPresetpresets.defaultA named feel: default, snappy or gentle.
transitionMorphTransition—open, close and shared springs. Wins over preset.
reduceMotionbooleanmedia queryForce the reduced-motion crossfade.
previewbooleanfalseTurns 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

Iris.Sheet props
PropTypeDefaultDescription
aria-label / -labelledbystringrequiredA missing accessible name is a type error.
dismissOnSwipebooleantrueDrag the sheet down past threshold to close.
dismissOnBackdropbooleantrueClick outside the sheet to close.
aspectRationumber—Width / height. Contain-fits inside sheetMaxWidth.
initialFocusRefObjectpanelControl 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.

Iris.Media props
PropTypeDefaultDescription
posterstringrequiredThe first frame. What reduced-motion users see, paused.
aspectRationumberrequiredWidth / height. Sizes the media before it loads, so it never squashes.
srcstring—The video. Omit it for a still image.
altstring—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.

Media sheet
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>

<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.

LinkPreview.tsx
"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

  • A circle triggerA circle trigger

    "circle"circle9999px

  • A squircle triggerA squircle trigger

    "squircle"squirclesuperellipse

  • A rounded-square triggerA rounded-square trigger

    "rounded-square"rounded25%

  • A square triggerA square trigger

    "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 default

    Dark

    README recipe

    Grey

    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+.