a player or talking head that follows the reader while its spot is off screen
npx shadcn add @ag/floatingAlready installed? Preview an update
npx shadcn add @ag/floating --dry-runYour 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 inbutton, no longerliquid-glass.
the demo above, verbatim
"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
"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>
)
}