Lift

Long-press any element and it lifts above the page, with the background dimmed and blurred, and your menu opens next to it. Slide the same finger onto an item to pick it. The iOS context menu, for the web; with Liquid Blur, the menu can be glass.

On this page

Get started

Lift takes an element and your menu. Hold the element for half a second (or right-click it), and it rises above the page in a modal layer, with the rest of the page dimmed and blurred. The menu opens below it, or above it if there’s no room below. You write the menu; Lift positions, opens and closes it.

  1. Install.

    pnpm add @fluidkit/lift
  2. Add a hidden menu anywhere on the page. Its buttons, links and role="menuitem" elements are the items.

    <img class="photo" src="…" alt="…" tabindex="0" />
    
    <div class="menu" role="menu" aria-label="Photo" hidden>
      <button role="menuitem">Copy</button>
      <button role="menuitem">Share</button>
      <button role="menuitem">Delete</button>
    </div>
  3. Create the lift once the elements are in the DOM.

    import { createLift } from "@fluidkit/lift";
    
    const lift = createLift({
      element: document.querySelector(".photo")!,
      menu: document.querySelector(".menu")!,
      onSelect: (item) => console.log(item.textContent),
    });
    
    // On unmount:
    // lift.destroy();
  1. Install. The React components are included in the package (React 18+).

    pnpm add @fluidkit/lift
  2. Add the parts. Put the target, the menu and its items inside Lift.Root. The menu stays hidden until the target is lifted, and onSelect receives the value of the chosen item.

    import { Lift } from "@fluidkit/lift/react";
    
    export function Photo({ src }: { src: string }) {
      return (
        <Lift.Root onSelect={(value) => console.log(value)}>
          <Lift.Target render={<img className="photo" src={src} alt="" tabIndex={0} />} />
          <Lift.Menu className="menu" aria-label="Photo">
            <Lift.Item value="copy">Copy</Lift.Item>
            <Lift.Item value="share">Share</Lift.Item>
            <Lift.Item value="delete">Delete</Lift.Item>
          </Lift.Menu>
        </Lift.Root>
      );
    }

    Components are marked "use client". On the server they render plain markup. A component that passes callbacks to them must be a client component too.

  1. Install. The Vue components are included in the package (Vue 3.5+).

    pnpm add @fluidkit/lift
  2. Add the parts. Put the target, the menu and its items inside Lift.Root. The menu stays hidden until the target is lifted, and @select receives the value of the chosen item.

    <script setup lang="ts">
    import { Lift } from "@fluidkit/lift/vue";
    
    defineProps<{ src: string }>();
    </script>
    
    <template>
      <Lift.Root @select="(value) => console.log(value)">
        <Lift.Target as-child>
          <img class="photo" :src="src" alt="" tabindex="0" />
        </Lift.Target>
        <Lift.Menu class="menu" aria-label="Photo">
          <Lift.Item value="copy">Copy</Lift.Item>
          <Lift.Item value="share">Share</Lift.Item>
          <Lift.Item value="delete">Delete</Lift.Item>
        </Lift.Menu>
      </Lift.Root>
    </template>

    On the server, including Nuxt, the components render plain markup; the lift starts working after hydration.

  1. Install. The Svelte components are included in the package (Svelte 5.29+).

    pnpm add @fluidkit/lift
  2. Add the parts. Put the target, the menu and its items inside Lift.Root. The menu stays hidden until the target is lifted, and onSelect receives the value of the chosen item.

    <script lang="ts">
      import { Lift } from "@fluidkit/lift/svelte";
    
      let { src }: { src: string } = $props();
    </script>
    
    <Lift.Root onSelect={(value) => console.log(value)}>
      <Lift.Target>
        {#snippet child({ props })}
          <img {...props} class="photo" {src} alt="" tabindex={0} />
        {/snippet}
      </Lift.Target>
      <Lift.Menu class="menu" aria-label="Photo">
        <Lift.Item value="copy">Copy</Lift.Item>
        <Lift.Item value="share">Share</Lift.Item>
        <Lift.Item value="delete">Delete</Lift.Item>
      </Lift.Menu>
    </Lift.Root>

    On the server, including SvelteKit, the components render plain markup; the lift starts working after hydration. Style the parts from a global stylesheet or with :global(): a component’s scoped styles don’t reach the elements the parts render.

Examples

Photos

Hold a photo, or right-click it. It shrinks slightly while held (data-lifting), then lifts, and the menu opens below it (or above, near the bottom of the screen). Keep your finger down and slide onto an item to pick it on release. The highlight is an Indicator driven by onHighlight. In the TypeScript version, six photos share one menu; in React, Vue and Svelte, each photo has its own.

A message

A chat bubble with a row of reactions above its actions. The menu is a single element, so it can have any layout. The chosen reaction appears on the bubble via onSelect.

Are we still on for Friday?
Yes! 8pm at the usual place
Perfect, I'll book a table

An app icon

Home screen icons with quick actions. What lifts is a copy of the icon with all its styles, so it looks the same outside its place.

Notes
Music
Maps
Photos
Mail
Weather
Camera
Clock

OptionsTypeScript

createLift(options: LiftOptions): Lift

Option Description
element: HTMLElement The element to hold and lift
menu?: HTMLElement Your menu, hidden with hidden until opened. Without it, only the element lifts
items?: string Selector for items inside the menu. Default: [role="menuitem"], button, a[href]
preview?: HTMLElement | (element) => HTMLElement Lifted instead of a copy, like a live or larger preview in iOS. Keeps its own size, centered on the element. An element from the page goes back to its place afterwards
delay?: number How long to hold, in ms. Default: 500
scale?: number How much larger the lifted element gets. Default: 1.04
dim?: number How dark the page gets, 0 to 1. Default: 0.2
blur?: number How much the page is blurred, in px. Default: 12
placement?: "auto" | "below" | "above" Where the menu opens. Default: auto, based on available space
spring?: SpringParams The lift animation spring. Default: defaultLiftSpring. See core
onOpen?: () => void Called when it starts lifting
onClose?: () => void Called when it’s closed and back in place
onHighlight?: (item) => void Called when the finger that lifted it moves over an item, or null when it leaves the items
onSelect?: (item) => void | boolean Called when an item is chosen by click, keyboard or releasing the finger on it. The menu then closes, unless you return false (for a toggle, say). Never called for disabled items (:disabled, aria-disabled="true")

APITypeScript

Member Description
opened true while lifted and the menu is open
open() Lifts the element and opens the menu, focusing the first item. Useful for a “more” button. Closes any other open lift on the page
close() Closes the menu and puts the element back
set(settings) Updates any option except element and menu without recreating the lift. While open, dim, blur and callbacks apply right away; the rest on next open
destroy() Closes it immediately and removes its listeners

PartsReactVueSvelte

Part Renders Description
Lift.Root nothing Holds the props below and connects the parts inside it
Lift.Target div The element to hold and lift. Has data-state="open" while lifted
Lift.Menu div role="menu" hidden The menu, hidden until opened
Lift.Item button role="menuitem" A menu item. value is passed to the root’s onSelect. disabled prevents choosing it. Its own onSelect(event) runs first; call event.preventDefault() to keep the menu open
Lift.Separator div role="separator" A divider, skipped by the finger and the keyboard
Lift.Preview div, hidden Lifted instead of a copy, at its own size: something live, or larger

Lift.Root props:

Prop Description
onSelect?: (value, item) => void Called with the value and element of the chosen item. The menu then closes, unless the item’s own onSelect prevented it. Never called for disabled items
open?: boolean Whether it’s open, for controlled use
defaultOpen?: boolean Whether it starts open, for uncontrolled use
onOpenChange?: (open) => void Called when it opens or closes
onHighlight?: (item) => void Called when the finger that lifted it moves over an item, or null when it leaves the items
delay?: number How long to hold, in ms. Default: 500
scale?: number How much larger the lifted element gets. Default: 1.04
dim?: number How dark the page gets, 0 to 1. Default: 0.2
blur?: number How much the page is blurred, in px. Default: 12
placement?: "auto" | "below" | "above" Where the menu opens. Default: auto
spring?: SpringParams The lift animation spring. Default: defaultLiftSpring. See core
items?: string Selector for items inside the menu. Default: Lift.Items, buttons and links

Controlled. Pass open and onOpenChange to keep the open state in your component.

const [open, setOpen] = useState(false);
<Lift.Root open={open} onOpenChange={setOpen}>…</Lift.Root>

Changing props never recreates the lift, and callbacks always see the latest values, so inline arrow functions are fine. A new target or menu element recreates it.

Part Renders Description
Lift.Root nothing Holds the props below and connects the parts inside it
Lift.Target div The element to hold and lift. Has data-state="open" while lifted
Lift.Menu div role="menu" hidden The menu, hidden until opened
Lift.Item button role="menuitem" A menu item. value is passed to the root’s @select. disabled prevents choosing it. Its own @select="(event) => …" runs first; call event.preventDefault() to keep the menu open
Lift.Separator div role="separator" A divider, skipped by the finger and the keyboard
Lift.Preview div, hidden Lifted instead of a copy, at its own size: something live, or larger

Every part also takes as-child, see Composition.

Lift.Root props and events:

Prop or event Description
@select="(value, item) => …" An item was chosen. Receives its value and element. The menu then closes, unless the item’s own @select prevented it. Never fires for disabled items
v-model:open Whether it’s open, for controlled use
defaultOpen?: boolean Whether it starts open, for uncontrolled use
@highlight="(item) => …" The finger that lifted it moved over an item, or off the items (null)
delay?: number How long to hold, in ms. Default: 500
scale?: number How much larger the lifted element gets. Default: 1.04
dim?: number How dark the page gets, 0 to 1. Default: 0.2
blur?: number How much the page is blurred, in px. Default: 12
placement?: "auto" | "below" | "above" Where the menu opens. Default: auto
spring?: SpringParams The lift animation spring. Default: defaultLiftSpring. See core
items?: string Selector for items inside the menu. Default: Lift.Items, buttons and links

Controlled. Use v-model:open to keep the open state in your component.

<Lift.Root v-model:open="open">…</Lift.Root>

Changing props never recreates the lift. A new target or menu element recreates it.

Part Renders Description
Lift.Root nothing Holds the props below and connects the parts inside it
Lift.Target div The element to hold and lift. Has data-state="open" while lifted
Lift.Menu div role="menu" hidden The menu, hidden until opened
Lift.Item button role="menuitem" A menu item. value is passed to the root’s onSelect. disabled prevents choosing it. Its own onSelect={(event) => …} runs first; call event.preventDefault() to keep the menu open
Lift.Separator div role="separator" A divider, skipped by the finger and the keyboard
Lift.Preview div, hidden Lifted instead of a copy, at its own size: something live, or larger

Every part also takes a child snippet, see Composition.

Lift.Root props:

Prop Description
onSelect={(value, item) => …} An item was chosen. Receives its value and element. The menu then closes, unless the item’s own onSelect prevented it. Never fires for disabled items
bind:open Whether it’s open, for controlled use
defaultOpen?: boolean Whether it starts open, for uncontrolled use
onOpenChange={(open) => …} It opened or closed
onHighlight={(item) => …} The finger that lifted it moved over an item, or off the items (null)
delay?: number How long to hold, in ms. Default: 500
scale?: number How much larger the lifted element gets. Default: 1.04
dim?: number How dark the page gets, 0 to 1. Default: 0.2
blur?: number How much the page is blurred, in px. Default: 12
placement?: "auto" | "below" | "above" Where the menu opens. Default: auto
spring?: SpringParams The lift animation spring. Default: defaultLiftSpring. See core
items?: string Selector for items inside the menu. Default: Lift.Items, buttons and links

Controlled. Use bind:open to keep the open state in your component.

<Lift.Root bind:open>…</Lift.Root>

Changing props never recreates the lift. A new target or menu element recreates it.

CompositionReactVueSvelte

Render as your own element. Every part accepts render, as in Base UI: pass an element and our props are merged into it (classes joined, styles, handlers and refs combined), or a function that receives the props and state.

<Lift.Target render={<img src={src} alt="" />} />
<Lift.Target render={(props, { opened }) => <img {...props} src={opened ? large : src} alt="" />} />

With an indicator. The highlight under the finger is an Indicator. onHighlight tells it which item is under the finger (active), and render makes each lift item an indicator item too.

const [pointed, setPointed] = useState<string | null>(null);

<Lift.Root onHighlight={(item) => setPointed(item?.dataset.value ?? null)} onSelect={act}>
  <Lift.Target render={<img src={src} alt="" />} />
  <Lift.Menu className="menu lb">
    <Indicator.Root active={pointed}>
      <Indicator.Thumb className="highlight" />
      <Lift.Item value="copy" render={<Indicator.Item value="copy" />}>Copy</Lift.Item>
      <Lift.Item value="share" render={<Indicator.Item value="share" />}>Share</Lift.Item>
    </Indicator.Root>
  </Lift.Menu>
</Lift.Root>

Styling. Parts expose state as data attributes: data-state="open" on the target and menu, data-lifting while held, data-highlighted on the item under the finger, data-disabled on disabled items.

Render as your own element. Every part accepts as-child, as in Reka UI: instead of its own element, the part renders the first element in its slot, with our attributes, classes, styles, listeners and refs merged in. The slot also receives the part’s state.

<Lift.Target as-child>
  <img :src="src" alt="" />
</Lift.Target>
<Lift.Target v-slot="{ opened }" as-child>
  <img :src="opened ? large : src" alt="" />
</Lift.Target>

With an indicator. The highlight under the finger is an Indicator. @highlight tells it which item is under the finger (active), and as-child makes each lift item an indicator item too.

<Lift.Root @highlight="(item) => (pointed = item?.dataset.value ?? null)" @select="act">
  <Lift.Target as-child><img :src="src" alt="" /></Lift.Target>
  <Lift.Menu class="menu lb">
    <Indicator.Root :active="pointed">
      <Indicator.Thumb class="highlight" />
      <Lift.Item value="copy" as-child><Indicator.Item value="copy">Copy</Indicator.Item></Lift.Item>
      <Lift.Item value="share" as-child><Indicator.Item value="share">Share</Indicator.Item></Lift.Item>
    </Indicator.Root>
  </Lift.Menu>
</Lift.Root>

Styling. Parts expose state as data attributes: data-state="open" on the target and menu, data-lifting while held, data-highlighted on the item under the finger, data-disabled on disabled items.

Render as your own element. Every part accepts a child snippet, as in bits-ui: instead of its own element, the part renders yours. Spread the props it gives onto your element: they carry our attributes, handlers and the attachment that connects it. The snippet also receives the part’s state.

<Lift.Target>
  {#snippet child({ props })}<img {...props} {src} alt="" />{/snippet}
</Lift.Target>
<Lift.Target>
  {#snippet child({ props, opened })}<img {...props} src={opened ? large : src} alt="" />{/snippet}
</Lift.Target>

Put classes and other attributes on the part itself (<Lift.Target class="photo">): they come in props with ours. An attribute written after the spread replaces the one props gave.

With an indicator. The highlight under the finger is an Indicator. onHighlight tells it which item is under the finger (active), and a child snippet makes each lift item an indicator item too.

<Lift.Root onHighlight={(item) => (pointed = item?.dataset.value ?? null)} onSelect={act}>
  <Lift.Target>
    {#snippet child({ props })}<img {...props} {src} alt="" />{/snippet}
  </Lift.Target>
  <Lift.Menu class="menu lb">
    <Indicator.Root active={pointed}>
      <Indicator.Thumb class="highlight" />
      <Lift.Item value="copy">
        {#snippet child({ props })}<Indicator.Item {...props} value="copy">Copy</Indicator.Item>{/snippet}
      </Lift.Item>
      <Lift.Item value="share">
        {#snippet child({ props })}<Indicator.Item {...props} value="share">Share</Indicator.Item>{/snippet}
      </Lift.Item>
    </Indicator.Root>
  </Lift.Menu>
</Lift.Root>

Styling. Parts expose state as data attributes: data-state="open" on the target and menu, data-lifting while held, data-highlighted on the item under the finger, data-disabled on disabled items.

The hookReactVueSvelte

Use useLift(options) when the parts don’t fit your markup. It takes the same options as Lift.Root (except onSelect receives the item element) and returns refs.

import { useLift } from "@fluidkit/lift/react";

const lift = useLift({ onSelect: (item) => act(item.dataset.action) });

<img ref={lift.element} src={src} alt="" />
<div>
  <div ref={lift.menu} role="menu" hidden>…</div>
</div>
Member Description
element, menu, preview Refs for the lifted element, the menu and a custom preview
opened true while open
open(), close() Open (focusing the first item) or close
lift The underlying engine, once mounted

While open, the menu is moved into Lift’s dialog. Wrap it in its own parent element, as above, so React doesn’t get confused by the missing node. The parts do this for you. If the component unmounts while open, the lift closes first and everything goes back where React expects it.

Use useLift(options) when the parts don’t fit your markup. Options can be an object, a ref or a getter; a getter is read again when what it uses changes. It returns function refs for :ref.

<script setup lang="ts">
import { useLift } from "@fluidkit/lift/vue";

const lift = useLift({ onSelect: (item) => act(item.dataset.action) });
</script>

<template>
  <img :ref="lift.element" :src="src" alt="" />
  <div>
    <div :ref="lift.menu" role="menu" hidden>…</div>
  </div>
</template>
Member Description
element, menu, preview Function refs for the lifted element, the menu and a custom preview
opened Ref: true while open
open(), close() Open (focusing the first item) or close
lift Shallow ref to the underlying engine, once mounted

While open, the menu is moved into Lift’s dialog. Wrap it in its own parent element, as above, so Vue doesn’t get confused by the missing node. The parts do this for you. If the component unmounts while open, the lift closes first and everything goes back where Vue expects it.

Use useLift(() => options) when the parts don’t fit your markup. Call it in a component’s script; the options getter is read again when what it uses changes. It returns attachments for {@attach}, and state you read like any other.

<script lang="ts">
  import { useLift } from "@fluidkit/lift/svelte";

  let { src }: { src: string } = $props();
  const lift = useLift(() => ({ onSelect: (item) => act(item.dataset.action) }));
</script>

<img {@attach lift.element} {src} alt="" />
<div>
  <div {@attach lift.menu} role="menu" hidden>…</div>
</div>
Member Description
element, menu, preview Attachments for the lifted element, the menu and a custom preview
opened true while open
open(), close() Open (focusing the first item) or close
lift The underlying engine, once mounted

While open, the menu is moved into Lift’s dialog. Wrap it in its own parent element, as above, so Svelte doesn’t lose track of it. The parts do this for you. If the component unmounts while open, the lift closes first and everything goes back where Svelte expects it.

How it works

The press

Hold still for delay and it lifts. Move more than 8px, or start scrolling, and it doesn’t. While held, the element has data-lifting and page text can’t be selected. Right-click or the context menu key opens it immediately. The click at the end of a long press is cancelled.

A copy

What lifts is a copy of the element with all its computed styles, so it looks the same outside its place. The original stays hidden in place, keeping its space. The copy is a snapshot: iframes become empty boxes, videos show their current frame, and form controls are detached so they don’t affect the page’s forms. For something live or larger, use preview.

Modal layer

The copy and menu sit in a modal <dialog> in the top layer, above any overflow, z-index or transform on the page. The rest of the page is inert and doesn’t scroll. Escape closes it and returns focus. Arrow keys move between items (skipping disabled ones), Home and End jump to the ends. Only one lift can be open at a time.

One finger

The finger that lifted the element can stay down and slide onto an item. The item under it gets data-highlighted and is passed to onHighlight; releasing there clicks it. If you release without moving, the menu stays open for a tap. When opened with the keyboard, the first item is focused.

Placement

The menu opens below the element, or above it if there’s no room, aligned to the element’s side of the screen and 12px from the edges. If there’s no room either way, the element moves up to make space, like in iOS. Changing the window width closes it; changing the height (mobile toolbars, the keyboard) doesn’t.

Animation

The copy rises from where the element is drawn to slightly larger, with the dim and the menu fading in. On close, it lands exactly on the element, even if the page scrolled meanwhile. With reduced motion, everything is instant.

With Liquid Blur

The menu can be glass (lb): inside the dialog it blurs the dimmed page behind it. For a highlight that follows the pointer, use an Indicator: it follows the mouse on its own, and onHighlight moves it for the finger that lifted the menu.

In React, pass the item from onHighlight to the indicator’s active. See Composition.

In Vue, pass the item from @highlight to the indicator’s active. See Composition.

In Svelte, pass the item from onHighlight to the indicator’s active. See Composition.

Browser support

Any browser with <dialog> and showModal(), Pointer Events and backdrop-filter.

Limitations

  • The copy is a snapshot: videos show one frame, iframes are empty, forms show their values at the time. Use preview for something live.
  • Pseudo-elements in the copy are only styled if the page’s selectors still match it outside its place.
  • Copying styles takes time: a few dozen elements take a few milliseconds, hundreds can take a frame or more on a phone and delay the lift. For large elements, use a smaller preview.
  • While open, the menu is moved into the dialog. Style it by its own classes, not by its position (.card .menu), and set the CSS variables it needs on the menu itself or on the root.
  • To let the finger slide onto the menu, a lifted element listens to touchmove non-passively, so a scroll that starts on it waits for the main thread.
PlaygroundIts hard cases, live: slowed down, on other backdrops, with their boxes drawn.