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.
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.
Install.
pnpm add @fluidkit/liftAdd 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>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();
Install. The React components are included in the package (React 18+).
pnpm add @fluidkit/liftAdd the parts. Put the target, the menu and its items inside
Lift.Root. The menu stays hidden until the target is lifted, andonSelectreceives thevalueof 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.
Install. The Vue components are included in the package (Vue 3.5+).
pnpm add @fluidkit/liftAdd the parts. Put the target, the menu and its items inside
Lift.Root. The menu stays hidden until the target is lifted, and@selectreceives thevalueof 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.
Install. The Svelte components are included in the package (Svelte 5.29+).
pnpm add @fluidkit/liftAdd the parts. Put the target, the menu and its items inside
Lift.Root. The menu stays hidden until the target is lifted, andonSelectreceives thevalueof 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.
import "@fluidkit/blur/style.css";
import { createIndicator } from "@fluidkit/indicator";
import { createLift } from "@fluidkit/lift";
const menu = document.querySelector<HTMLElement>(".menu")!;
const highlight = createIndicator({ container: menu, indicator: menu.querySelector(".menu__highlight")! });
for (const photo of document.querySelectorAll<HTMLElement>(".photo")) {
createLift({
element: photo,
menu,
// The highlight follows the finger as it slides over the items
onHighlight: (item) => highlight.to(item),
onClose: () => highlight.to(null, { instant: true }),
});
}import "@fluidkit/blur/style.css";
import { useState } from "react";
import { Indicator } from "@fluidkit/indicator/react";
import { Lift } from "@fluidkit/lift/react";
const items = ["Copy", "Share", "Favorite", "Delete"];
export function Photo({ src }: { src: string }) {
// The item under the finger, where the highlight goes
const [pointed, setPointed] = useState<string | null>(null);
return (
<Lift.Root onHighlight={(item) => setPointed(item?.dataset.value ?? null)} onSelect={(value) => act(value, src)}>
<Lift.Target render={<img className="photo" src={src} alt="" tabIndex={0} />} />
<Lift.Menu className="menu lb" aria-label="Photo">
<Indicator.Root active={pointed}>
<Indicator.Thumb className="menu__highlight" />
{items.map((label) => (
<Lift.Item key={label} value={label} render={<Indicator.Item value={label} />}>
{label}
</Lift.Item>
))}
</Indicator.Root>
</Lift.Menu>
</Lift.Root>
);
}<script setup lang="ts">
import "@fluidkit/blur/style.css";
import { ref } from "vue";
import { Indicator } from "@fluidkit/indicator/vue";
import { Lift } from "@fluidkit/lift/vue";
defineProps<{ src: string }>();
const items = ["Copy", "Share", "Favorite", "Delete"];
// The item under the finger, where the highlight goes
const pointed = ref<string | null>(null);
const act = (value: string) => {
/* … */
};
</script>
<template>
<Lift.Root @highlight="(item) => (pointed = item?.dataset.value ?? null)" @select="act">
<Lift.Target as-child>
<img class="photo" :src="src" alt="" tabindex="0" />
</Lift.Target>
<Lift.Menu class="menu lb" aria-label="Photo">
<Indicator.Root :active="pointed">
<Indicator.Thumb class="menu__highlight" />
<Lift.Item v-for="label in items" :key="label" :value="label" as-child>
<Indicator.Item :value="label">{{ label }}</Indicator.Item>
</Lift.Item>
</Indicator.Root>
</Lift.Menu>
</Lift.Root>
</template><script lang="ts">
import "@fluidkit/blur/style.css";
import { Indicator } from "@fluidkit/indicator/svelte";
import { Lift } from "@fluidkit/lift/svelte";
let { src }: { src: string } = $props();
const items = ["Copy", "Share", "Favorite", "Delete"];
// The item under the finger, where the highlight goes
let pointed = $state<string | null>(null);
const act = (value: string) => {
/* … */
};
</script>
<Lift.Root onHighlight={(item) => (pointed = item?.dataset.value ?? null)} onSelect={act}>
<Lift.Target>
{#snippet child({ props })}
<img {...props} class="photo" {src} alt="" tabindex={0} />
{/snippet}
</Lift.Target>
<Lift.Menu class="menu lb" aria-label="Photo">
<Indicator.Root active={pointed}>
<Indicator.Thumb class="menu__highlight" />
{#each items as label (label)}
<Lift.Item value={label}>
{#snippet child({ props })}
<Indicator.Item {...props} value={label}>{label}</Indicator.Item>
{/snippet}
</Lift.Item>
{/each}
</Indicator.Root>
</Lift.Menu>
</Lift.Root>.photo { border-radius: 14px; user-select: none; transition: scale 0.4s; }
/* Shrinks slightly while held, before it lifts */
.photo[data-lifting] { scale: 0.96; }
/* Keep `hidden` working: a `display` of its own would override it */
.menu[hidden] { display: none; }
.menu { width: 230px; display: grid; padding: 6px; border-radius: 24px; }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.
<div class="bubble" tabindex="0">Yes! 8pm at the usual place</div>
<div class="stack" role="menu" aria-label="Message" hidden>
<div class="reactions lb">
<button role="menuitem" data-reaction="❤️">❤️</button>
…
</div>
<div class="menu lb">
<button role="menuitem">Reply</button>
…
</div>
</div>createLift({
element: bubble,
menu: stack,
onSelect: (item) => {
if (item.dataset.reaction) bubble.dataset.reaction = item.dataset.reaction;
},
});const reactions = ["❤️", "👍", "😂", "😮", "😢", "🔥"];
export function Bubble({ text }: { text: string }) {
const [reaction, setReaction] = useState<string | null>(null);
return (
<Lift.Root onSelect={(value) => reactions.includes(value) && setReaction((r) => (r === value ? null : value))}>
<Lift.Target className="bubble" tabIndex={0}>
{text}
{reaction && <span className="bubble__reaction">{reaction}</span>}
</Lift.Target>
<Lift.Menu className="stack" aria-label="Message">
<div className="reactions lb" role="group" aria-label="React">
{reactions.map((r) => (
<Lift.Item key={r} value={r} aria-label={`React ${r}`}>
{r}
</Lift.Item>
))}
</div>
<div className="menu lb">
<Lift.Item value="reply">Reply</Lift.Item>
<Lift.Item value="copy">Copy</Lift.Item>
<Lift.Item value="delete">Delete</Lift.Item>
</div>
</Lift.Menu>
</Lift.Root>
);
}<script setup lang="ts">
import { ref } from "vue";
import { Lift } from "@fluidkit/lift/vue";
defineProps<{ text: string }>();
const reactions = ["❤️", "👍", "😂", "😮", "😢", "🔥"];
const reaction = ref<string | null>(null);
const choose = (value: string) => {
if (reactions.includes(value)) reaction.value = reaction.value === value ? null : value;
};
</script>
<template>
<Lift.Root @select="choose">
<Lift.Target class="bubble" tabindex="0">
{{ text }}
<span v-if="reaction" class="bubble__reaction">{{ reaction }}</span>
</Lift.Target>
<Lift.Menu class="stack" aria-label="Message">
<div class="reactions lb" role="group" aria-label="React">
<Lift.Item v-for="r in reactions" :key="r" :value="r" :aria-label="`React ${r}`">{{ r }}</Lift.Item>
</div>
<div class="menu lb">
<Lift.Item value="reply">Reply</Lift.Item>
<Lift.Item value="copy">Copy</Lift.Item>
<Lift.Item value="delete">Delete</Lift.Item>
</div>
</Lift.Menu>
</Lift.Root>
</template><script lang="ts">
import { Lift } from "@fluidkit/lift/svelte";
let { text }: { text: string } = $props();
const reactions = ["❤️", "👍", "😂", "😮", "😢", "🔥"];
let reaction = $state<string | null>(null);
const choose = (value: string) => {
if (reactions.includes(value)) reaction = reaction === value ? null : value;
};
</script>
<Lift.Root onSelect={choose}>
<Lift.Target class="bubble" tabindex={0}>
{text}
{#if reaction}<span class="bubble__reaction">{reaction}</span>{/if}
</Lift.Target>
<Lift.Menu class="stack" aria-label="Message">
<div class="reactions lb" role="group" aria-label="React">
{#each reactions as r (r)}
<Lift.Item value={r} aria-label={`React ${r}`}>{r}</Lift.Item>
{/each}
</div>
<div class="menu lb">
<Lift.Item value="reply">Reply</Lift.Item>
<Lift.Item value="copy">Copy</Lift.Item>
<Lift.Item value="delete">Delete</Lift.Item>
</div>
</Lift.Menu>
</Lift.Root>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.
export function AppIcon({ app }: { app: App }) {
return (
<Lift.Root onSelect={(value) => run(app, value)}>
<Lift.Target className="icon" style={{ background: app.gradient }} aria-label={app.name} tabIndex={0}>
{app.glyph}
</Lift.Target>
<Lift.Menu className="menu lb" aria-label={app.name}>
<Lift.Item value="new">New Item</Lift.Item>
<Lift.Item value="search">Search</Lift.Item>
<Lift.Separator />
<Lift.Item value="edit">Edit Home Screen</Lift.Item>
<Lift.Item value="remove" className="danger">
Remove App
</Lift.Item>
</Lift.Menu>
</Lift.Root>
);
}<script setup lang="ts">
import { Lift } from "@fluidkit/lift/vue";
defineProps<{ app: { name: string; glyph: string; gradient: string } }>();
const run = (action: string) => {
/* … */
};
</script>
<template>
<Lift.Root @select="run">
<Lift.Target class="icon" :style="{ background: app.gradient }" :aria-label="app.name" tabindex="0">
{{ app.glyph }}
</Lift.Target>
<Lift.Menu class="menu lb" :aria-label="app.name">
<Lift.Item value="new">New Item</Lift.Item>
<Lift.Item value="search">Search</Lift.Item>
<Lift.Separator />
<Lift.Item value="edit">Edit Home Screen</Lift.Item>
<Lift.Item value="remove" class="danger">Remove App</Lift.Item>
</Lift.Menu>
</Lift.Root>
</template><script lang="ts">
import { Lift } from "@fluidkit/lift/svelte";
let { app }: { app: { name: string; glyph: string; gradient: string } } = $props();
const run = (action: string) => {
/* … */
};
</script>
<Lift.Root onSelect={run}>
<Lift.Target class="icon" style="background: {app.gradient}" aria-label={app.name} tabindex={0}>
{app.glyph}
</Lift.Target>
<Lift.Menu class="menu lb" aria-label={app.name}>
<Lift.Item value="new">New Item</Lift.Item>
<Lift.Item value="search">Search</Lift.Item>
<Lift.Separator />
<Lift.Item value="edit">Edit Home Screen</Lift.Item>
<Lift.Item value="remove" class="danger">Remove App</Lift.Item>
</Lift.Menu>
</Lift.Root>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
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.
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.
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.
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.
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
previewfor 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
touchmovenon-passively, so a scroll that starts on it waits for the main thread.