← ui

floating

ui

a player or talking head that follows the reader while its spot is off screen

npx shadcn add @ag/floating
Already installed? Preview an update
npx shadcn add @ag/floating --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • Changed: its docked ways out are Button material="glass"; it pulls in button, no longer liquid-glass.
Updated 2026-10-10 · release notes

also called picture in picture, mini player

shelf media

uses button

the demo above, verbatim

floating.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { PreviewPicker } from "@/registry/base-nova/ui/preview-picker"
import { Floating, type FloatingMode } from "./floating"

// #region companion
/** A card that keeps you company: scroll it out of the window and it docks in a
 *  corner; drag it to another; the arrow takes you back to where it lives. A player
 *  passes `when={playing}` so only something playing follows you (sound-ribbon's
 *  talking head does). */
function Companion() {
  const [mode, setMode] = React.useState<FloatingMode>("auto")
  return (
    <div className="space-y-4">
      <PreviewPicker
        label="mode"
        value={mode}
        onChange={setMode}
        size="compact"
        options={[
          { value: "auto", label: "auto", hint: "docks once scrolled past" },
          { value: "always", label: "always" },
          { value: "off", label: "off" },
        ]}
      />
      <Floating mode={mode} label="the companion" className="w-fit">
        <div className="grid size-28 place-items-center rounded-2xl bg-foreground text-background text-sm shadow-xl">
          scroll on
        </div>
      </Floating>
    </div>
  )
}
// #endregion

export default function Demo() {
  return (
    <Sample name="companion" title="it follows you" with="companion">
      <Companion />
    </Sample>
  )
}

the source, every file an install writes

floating.tsx
"use client"

// FLOATING: something that leaves its place in the page to stay with the reader. A
// talking head reading an article aloud, a player you keep while you scroll on: its
// spot in the page keeps its size, and the thing itself floats over the window and
// follows you.
//
//   <Floating mode="auto" when={playing} label="Buzz Aldrin">
//     <SoundRibbon variant="halo">…the face…</SoundRibbon>
//   </Floating>
//
// `mode`: "off" stays in the page; "auto" floats while `when` holds and its spot is
// leaving the window; "always" floats. The children are never remounted: the same
// element moves (a video keeps playing, its sound tap and clock intact).
//
// MAGNETIC, NEVER A JUMP, NEVER HALF WAY. It belongs in one of two places: its spot in
// the page, or its place on the window. It belongs docked the moment its spot reaches
// the window's edge, and home again once the spot is back `reach` px inside. A spring
// carries how far along the journey it is, and its position is always that share of
// the way between the spot AS IT IS NOW and its place: a scroll carries it rigidly with
// its spot (it never trails behind a fast scroll), and a play starting with the spot
// off screen, a snap after a drag, a window resized are all glides, never cuts. On the
// way it is held inside the window: almost out, never out. Docked it may shrink
// (`scale`). Reduced motion: no spring, it is where it belongs.
//
// WHERE IT FLOATS: a place on the window as two fractions, `x` across and `y` down (0
// the left or top edge, 1 the right or bottom, the margin kept from both), so a place
// means the same on any window. It STICKS to `spots` (the corners and the middles of
// the edges by default; a page can program its own, a reading column's gutter, a rail
// down one side): a drag let go within `magnet` px of one glides there; let go farther
// from all of them and it stays where it was left. A drag is not a click. Docked, a
// button scrolls back to its spot in the page.

import { Undo2, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"

export type FloatingMode = "off" | "auto" | "always"

/** A place on the window: fractions across and down, the margin kept from each edge. */
export interface FloatingSpot {
  x: number
  y: number
}

/** The corners and the middles of the edges. */
export const EDGE_SPOTS: readonly FloatingSpot[] = [
  { x: 0, y: 0 },
  { x: 0.5, y: 0 },
  { x: 1, y: 0 },
  { x: 0, y: 0.5 },
  { x: 1, y: 0.5 },
  { x: 0, y: 1 },
  { x: 0.5, y: 1 },
  { x: 1, y: 1 },
]

/** px a press travels before it is a drag, not a click. */
const DRAG_SLOP = 4
/** The spring's time constant, ms: how closely it follows the pull. */
const SPRING_MS = 110
/** Close enough to rest, px. */
const REST_PX = 0.3
/** px it keeps from the window's edges even mid-journey. */
const HOLD_PX = 6

const FloatingContext = React.createContext(false)

/** Whether the Floating around it is docked (more than half way to its place on the
 *  window): content that takes another look there. False outside a Floating. In CSS
 *  the same fact is `data-floating` on the spot (`group-data-floating/…`). */
export function useFloating(): boolean {
  return React.useContext(FloatingContext)
}

type Box = { x: number; y: number; s: number }

const smooth = (t: number) => t * t * (3 - 2 * t)

export function Floating({
  mode = "auto",
  when = true,
  spots = EDGE_SPOTS,
  start = { x: 1, y: 1 },
  magnet = 140,
  margin = 24,
  reach = 64,
  scale = 1,
  label,
  className,
  children,
}: {
  mode?: FloatingMode
  /** "auto": whether it may float now (a player's `playing`: a paused head stays home). */
  when?: boolean
  /** The places it sticks to. Empty: it stays wherever it is let go. */
  spots?: readonly FloatingSpot[]
  /** Where it floats first. */
  start?: FloatingSpot
  /** How near (px, between where it was let go and a spot) it sticks. */
  magnet?: number
  /** Its distance from the window's edges, px. */
  margin?: number
  /** px inside the window its spot must be back before it goes home (no flipping at
   *  the edge). */
  reach?: number
  /** Its size docked, a share of its size at home. */
  scale?: number
  /** What it is: the back button says "back to <label>". */
  label: string
  className?: string
  children: React.ReactNode
}) {
  const spot = React.useRef<HTMLDivElement>(null)
  const body = React.useRef<HTMLDivElement>(null)
  const [docked, setDocked] = React.useState(false)
  // Everything the frame loop reads, off React's render.
  const live = React.useRef({ mode, when, margin, reach, scale })
  live.current = { mode, when, margin, reach, scale }
  const place = React.useRef<FloatingSpot>(start)
  // The reader dragged it somewhere: that place is theirs, not the leaving edge's.
  const chosen = React.useRef(false)
  // The reader sent it home (×): it stays home until its spot has been back in sight.
  const dismissed = React.useRef(false)
  const drag = React.useRef<{ x: number; y: number } | null>(null)
  // How far along the journey it is shown, 0 home .. 1 docked: the one thing the
  // spring carries. Its position is that share of the way between the spot AS IT IS
  // NOW and its place on the window, so a scroll carries it with its spot rigidly.
  const journey = React.useRef(0)
  // Its place on the window as shown (springs to a new place after a drag), px.
  const dockAt = React.useRef<{ x: number; y: number } | null>(null)
  const kick = React.useRef<() => void>(() => {})

  React.useEffect(() => {
    const el = body.current
    const home = spot.current
    if (!el || !home) return
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)")
    let frame = 0
    let last = 0
    // Its size at home, measured while it sits there.
    let size = { w: el.offsetWidth, h: el.offsetHeight }

    // Where it belongs: home (0) or docked (1), never between. Docked the moment its
    // spot reaches the window's edge; home again once the spot is back `reach` px
    // inside (a scroll that hovers at the edge does not flip it back and forth).
    // It lifts off only while `when` holds (a head that is playing), but once docked
    // it stays docked whatever `when` does (a pause is not a goodbye): it goes home
    // when its spot is back in sight, or when the reader dismisses it (×).
    let belongs = 0
    const decide = (r: DOMRect, vh: number) => {
      const { mode: m, when: w, margin: gap, reach: back } = live.current
      const past = Math.max(gap - r.top, r.bottom - (vh - gap))
      if (past < -back) dismissed.current = false
      if (m === "off" || dismissed.current) belongs = 0
      else if (m === "always") belongs = 1
      else if (belongs === 0 && !w) belongs = 0
      else {
        if (past > 0 && belongs === 0 && !chosen.current)
          // It leaves by the edge its spot leaves by, and docks there: scrolled on
          // (the spot gone up) it goes up, scrolled back (gone down) it goes down.
          place.current = {
            x: place.current.x,
            y: gap - r.top > r.bottom - (vh - gap) ? 0 : 1,
          }
        if (past > 0) belongs = 1
        else if (past < -back) belongs = 0
      }
    }
    // Its place on the window, px, at its docked size.
    const dockTarget = (vw: number, vh: number) => {
      const { margin: gap, scale: s1 } = live.current
      const p = place.current
      return {
        x: gap + (vw - 2 * gap - size.w * s1) * p.x,
        y: gap + (vh - 2 * gap - size.h * s1) * p.y,
      }
    }
    const paint = (b: Box | null) => {
      // The spot holds the room the body leaves, so the page never moves.
      home.style.width = b ? `${size.w}px` : ""
      home.style.height = b ? `${size.h}px` : ""
      if (!b) {
        el.style.position = ""
        el.style.left = ""
        el.style.top = ""
        el.style.transform = ""
        el.style.zIndex = ""
        return
      }
      const d = drag.current
      el.style.position = "fixed"
      el.style.zIndex = "50"
      el.style.left = "0px"
      el.style.top = "0px"
      el.style.transformOrigin = "top left"
      el.style.transform = `translate(${b.x + (d?.x ?? 0)}px, ${b.y + (d?.y ?? 0)}px) scale(${b.s})`
    }
    const tick = (now: number) => {
      const dt = last ? Math.min(64, now - last) : 16
      last = now
      const r = home.getBoundingClientRect()
      const vw = document.documentElement.clientWidth
      const vh = document.documentElement.clientHeight
      decide(r, vh)
      setDocked(belongs === 1)
      const k = reduced.matches ? 1 : 1 - Math.exp(-dt / SPRING_MS)
      // The journey and the place spring; the spot is read as it is, every frame.
      const t0 = journey.current
      const t =
        Math.abs(belongs - t0) < 0.001 ? belongs : t0 + (belongs - t0) * k
      journey.current = t
      const goal = dockTarget(vw, vh)
      const at = dockAt.current ?? goal
      const dock = {
        x: at.x + (goal.x - at.x) * k,
        y: at.y + (goal.y - at.y) * k,
      }
      const placed =
        Math.abs(dock.x - goal.x) < REST_PX &&
        Math.abs(dock.y - goal.y) < REST_PX
      dockAt.current = placed ? goal : dock
      if (t === 0) {
        // Home: page content again, the spring at rest.
        paint(null)
        frame = 0
        last = 0
        return
      }
      const e = smooth(t)
      const s = 1 + (live.current.scale - 1) * e
      const clamp = (v: number, span: number, room: number) =>
        Math.min(Math.max(v, HOLD_PX), room - span - HOLD_PX)
      paint({
        // Held inside the window on the way: almost out, never out.
        x: clamp(r.left + (dockAt.current.x - r.left) * e, size.w * s, vw),
        y: clamp(r.top + (dockAt.current.y - r.top) * e, size.h * s, vh),
        s,
      })
      // Docked and still, it waits for the next scroll, resize or drag.
      frame =
        t === 1 && placed && !drag.current ? 0 : requestAnimationFrame(tick)
      if (!frame) last = 0
    }
    const wake = () => {
      if (journey.current === 0)
        size = { w: el.offsetWidth, h: el.offsetHeight }
      if (!frame) frame = requestAnimationFrame(tick)
    }
    kick.current = wake
    window.addEventListener("scroll", wake, { passive: true, capture: true })
    window.addEventListener("resize", wake)
    wake()
    return () => {
      cancelAnimationFrame(frame)
      window.removeEventListener("scroll", wake, { capture: true })
      window.removeEventListener("resize", wake)
      kick.current = () => {}
    }
  }, [])

  // A new word from the page (it starts or stops playing, another mode): the pull moves.
  // biome-ignore lint/correctness/useExhaustiveDependencies: the loop reads these through `live`; they are its wake-up
  React.useEffect(() => kick.current(), [mode, when, scale, margin, reach])

  // A drag: it follows the hand; let go, the place is the nearest spot within the
  // magnet or where it was left, and the spring takes it there. Not a click.
  const onPointerDown = (e: React.PointerEvent) => {
    if (!docked || e.button !== 0) return
    const target = e.target as Element
    if (target.closest("button, [role=slider], a, input")) return
    const el = e.currentTarget as HTMLElement
    const from = { x: e.clientX, y: e.clientY }
    let moved = false
    const move = (ev: PointerEvent) => {
      const x = ev.clientX - from.x
      const y = ev.clientY - from.y
      if (!moved && Math.hypot(x, y) < DRAG_SLOP) return
      if (!moved) el.setPointerCapture(ev.pointerId)
      moved = true
      drag.current = { x, y }
      kick.current()
    }
    const up = (ev: PointerEvent) => {
      window.removeEventListener("pointermove", move)
      window.removeEventListener("pointerup", up)
      window.removeEventListener("pointercancel", up)
      if (!moved) return
      el.releasePointerCapture(ev.pointerId)
      const r = el.getBoundingClientRect()
      const room = {
        w: Math.max(
          1,
          document.documentElement.clientWidth - 2 * margin - r.width,
        ),
        h: Math.max(
          1,
          document.documentElement.clientHeight - 2 * margin - r.height,
        ),
      }
      const clamp = (v: number) => Math.min(1, Math.max(0, v))
      const left = {
        x: clamp((r.left - margin) / room.w),
        y: clamp((r.top - margin) / room.h),
      }
      const distance = (s: FloatingSpot) =>
        Math.hypot((s.x - left.x) * room.w, (s.y - left.y) * room.h)
      const near = [...spots].sort((a, b) => distance(a) - distance(b))[0]
      place.current = near && distance(near) <= magnet ? near : left
      // A place the reader chose is kept, whichever way the spot leaves next.
      chosen.current = true
      // Where the hand left it is where the spring starts from.
      if (dockAt.current && drag.current)
        dockAt.current = {
          x: dockAt.current.x + drag.current.x,
          y: dockAt.current.y + drag.current.y,
        }
      drag.current = null
      kick.current()
      // The click that ends a drag lands on whatever is under the hand: swallow it.
      const swallow = (c: MouseEvent) => {
        c.stopPropagation()
        c.preventDefault()
      }
      window.addEventListener("click", swallow, { capture: true, once: true })
      window.setTimeout(
        () => window.removeEventListener("click", swallow, { capture: true }),
        0,
      )
    }
    window.addEventListener("pointermove", move)
    window.addEventListener("pointerup", up)
    window.addEventListener("pointercancel", up)
  }

  return (
    <div
      ref={spot}
      data-slot="floating"
      data-floating={docked || undefined}
      className={cn("relative", className)}
    >
      {/* The spot keeps its room while the body is away: the page never moves. */}
      <div
        ref={body}
        data-slot="floating-body"
        onPointerDown={onPointerDown}
        className={cn(
          "group/floating will-change-transform",
          docked && "cursor-grab touch-none active:cursor-grabbing",
        )}
      >
        <FloatingContext.Provider value={docked}>
          {children}
        </FloatingContext.Provider>
        {/* Docked, its two ways out, in glass, there only under the hand (and to a
            keyboard): back to its spot in the page, or home without going there. */}
        {docked && mode === "auto" && (
          <div className="absolute -top-3 -right-3 z-10 flex gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover/floating:opacity-100">
            <Button
              material="glass"
              size="icon"
              aria-label={`back to ${label}`}
              title={`back to ${label}`}
              onClick={() =>
                spot.current?.scrollIntoView({
                  behavior: window.matchMedia(
                    "(prefers-reduced-motion: reduce)",
                  ).matches
                    ? "auto"
                    : "smooth",
                  block: "center",
                })
              }
            >
              <Undo2 aria-hidden />
            </Button>
            <Button
              material="glass"
              size="icon"
              aria-label={`close ${label}`}
              title={`close ${label}`}
              onClick={() => {
                dismissed.current = true
                kick.current()
              }}
            >
              <X aria-hidden />
            </Button>
          </div>
        )}
      </div>
    </div>
  )
}