All components
Floating Panel
Draggable utility windows with a drag handle, title bar, and scrollable body.
123456789101112131415161718192021222324252627import { FloatingPanel } from "#components/ui/base-ui/floating-panel.tsx";import { Button } from "#components/ui/base-ui/button.tsx";
// Base UI ships no floating-panel machine — this is the recipe composed in// Base UI's idiom: useRender parts around controlled open/position state.// The positioner portals to <body>; the drag trigger moves the window with// pointer capture.export function Example() { return ( <FloatingPanel.Root defaultPosition={{ x: 120, y: 160 }}> <FloatingPanel.Trigger render={<Button variant="surface" />}> Open panel </FloatingPanel.Trigger> <FloatingPanel.Positioner> <FloatingPanel.Content> <FloatingPanel.Header> <FloatingPanel.DragTrigger> <FloatingPanel.Title>Console</FloatingPanel.Title> </FloatingPanel.DragTrigger> <FloatingPanel.CloseTrigger aria-label="Close">×</FloatingPanel.CloseTrigger> </FloatingPanel.Header> <FloatingPanel.Body>build completed in 412ms</FloatingPanel.Body> </FloatingPanel.Content> </FloatingPanel.Positioner> </FloatingPanel.Root> );}Install
1npx stylexblocks add floating-panelSource
components/ui/base-ui/floating-panel.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from "react";import { createPortal } from "react-dom";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { type StyleOverrideProps } from "#lib/recipe-props.ts";import { semanticRadii } from "@stylexblocks/ui/semantic-tokens/radii.stylex.ts";import { semanticShadows } from "@stylexblocks/ui/semantic-tokens/shadows.stylex.ts";import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'import { zIndices } from '@stylexblocks/ui/tokens/z-indices.stylex.ts'
const floatingPanelPositioner = stylex.create({ base: { '--floating-panel-z-index': zIndices.layer4, zIndex: 'calc(var(--floating-panel-z-index) + var(--layer-index, 0))', },})
const floatingPanelContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', overflow: 'hidden', outline: 'none', position: 'relative', transformOrigin: 'var(--transform-origin)', animationName: { default: null, ':is([data-open], [data-state="open"])': 'scale-in, fade-in', ':is([data-closed], [data-state="closed"])': 'scale-out, fade-out', }, animationDuration: { default: null, ':is([data-open], [data-state="open"])': durations.fast, ':is([data-closed], [data-state="closed"])': durations.faster, }, },})
const floatingPanelDragTrigger = stylex.create({ base: { flex: 1, display: 'flex', alignItems: 'center', gap: spacing._2, minWidth: 0, },})
const floatingPanelHeader = stylex.create({ base: { display: 'flex', alignItems: 'center', gap: spacing._2, paddingInline: spacing._3, paddingBlock: spacing._2, borderBottomWidth: '1px', backgroundColor: semanticColors.bgSubtle, flex: 'none', },})
const floatingPanelTitle = stylex.create({ base: { fontWeight: fontWeights.medium, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', },})
const floatingPanelBody = stylex.create({ base: { flex: 1, overflow: 'auto', padding: spacing._3, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, },})
const floatingPanelResizeTrigger = stylex.create({ base: { zIndex: 1, '--size': sizes._2_5, },})
// Local chrome the recipe leaves to the composition layer: the window// surface (with a sensible default footprint), a drag cursor on the handle,// an icon-button close trigger, and a real truncation for the title (the// generated `truncate` shorthand is not a CSS property).const chrome = stylex.create({ content: { backgroundColor: semanticColors.bgPanel, borderWidth: "1px", borderStyle: "solid", borderColor: semanticColors.border, borderRadius: semanticRadii.panel, boxShadow: semanticShadows.overlay, width: "20rem", maxWidth: "calc(100vw - 2rem)", maxHeight: "60vh", }, dragTrigger: { cursor: "move", userSelect: "none", touchAction: "none", }, title: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", }, closeTrigger: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: "1.5rem", height: "1.5rem", borderRadius: semanticRadii.control, borderStyle: "none", color: semanticColors.fgMuted, backgroundColor: { default: "transparent", ":hover": semanticColors.bgMuted }, cursor: "pointer", },});
interface Position { x: number; y: number;}
interface FloatingPanelContextValue { open: boolean; setOpen: (open: boolean) => void; position: Position; setPosition: (position: Position) => void; titleId: string;}
const FloatingPanelContext = createContext<FloatingPanelContextValue | null>(null);
function usePanel(): FloatingPanelContextValue { const ctx = useContext(FloatingPanelContext); if (!ctx) throw new Error("FloatingPanel parts must be used within <FloatingPanel.Root>"); return ctx;}
/** * Floating Panel — Base UI ships no floating-panel machine, so this is the * floating-panel slot recipe composed in Base UI's own idiom: `useRender` + * `mergeProps` parts around controlled open/position state. The positioner * portals to `document.body` and fixed-positions the window; the drag * trigger moves it with pointer capture. Every part composes via `render`. */function Root(props: { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; defaultPosition?: Position; children?: React.ReactNode;}) { const { open: openProp, defaultOpen, onOpenChange, defaultPosition, children } = props; const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false); const open = openProp ?? uncontrolledOpen; const [position, setPosition] = useState<Position>( defaultPosition ?? { x: 96, y: 96 }, ); const titleId = useId(); const value = useMemo<FloatingPanelContextValue>( () => ({ open, setOpen: (next) => { if (openProp === undefined) setUncontrolledOpen(next); onOpenChange?.(next); }, position, setPosition, titleId, }), // eslint-disable-next-line react-hooks/exhaustive-deps [open, openProp, position, titleId, onOpenChange], ); return ( <FloatingPanelContext.Provider value={value}> {children} </FloatingPanelContext.Provider> );}Root.displayName = "FloatingPanelRoot";
function Trigger(allProps: useRender.ComponentProps<"button"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const panel = usePanel(); const sx = stylex.props( ...([palette, style] as unknown as ReadonlyArray<never>), ); return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, props: mergeProps( { type: "button" as const, "aria-haspopup": "dialog" as const, "aria-expanded": panel.open, onClick: () => panel.setOpen(!panel.open), className: sx.className, style: sx.style, }, rest, ), });}Trigger.displayName = "FloatingPanelTrigger";
function Positioner(allProps: useRender.ComponentProps<"div"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const panel = usePanel(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const sx = stylex.props( ...([palette, floatingPanelPositioner.base, style] as unknown as ReadonlyArray<never>), ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps( { className: sx.className, style: { ...sx.style, position: "fixed" as const, insetInlineStart: panel.position.x, top: panel.position.y, }, }, rest, ), }); if (!panel.open || !mounted) return null; return createPortal(element, document.body);}Positioner.displayName = "FloatingPanelPositioner";
function Content(allProps: useRender.ComponentProps<"section"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const panel = usePanel(); const sx = stylex.props( ...([palette, floatingPanelContent.base, chrome.content, style] as unknown as ReadonlyArray<never>), ); return useRender({ defaultTagName: "section", render: render as useRender.RenderProp, props: mergeProps( { role: "dialog" as const, "aria-labelledby": panel.titleId, tabIndex: -1, onKeyDown: (event: React.KeyboardEvent) => { if (event.key === "Escape") panel.setOpen(false); }, className: sx.className, style: sx.style, }, rest, ), });}Content.displayName = "FloatingPanelContent";
function DragTrigger(allProps: useRender.ComponentProps<"div"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const panel = usePanel(); const dragState = useRef<{ startX: number; startY: number; origin: Position } | null>(null); const sx = stylex.props( ...([palette, floatingPanelDragTrigger.base, chrome.dragTrigger, style] as unknown as ReadonlyArray<never>), ); return useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps( { className: sx.className, style: sx.style, onPointerDown: (event: React.PointerEvent<HTMLElement>) => { dragState.current = { startX: event.clientX, startY: event.clientY, origin: panel.position, }; event.currentTarget.setPointerCapture(event.pointerId); }, onPointerMove: (event: React.PointerEvent<HTMLElement>) => { const state = dragState.current; if (!state) return; panel.setPosition({ x: Math.max(0, state.origin.x + event.clientX - state.startX), y: Math.max(0, state.origin.y + event.clientY - state.startY), }); }, onPointerUp: (event: React.PointerEvent<HTMLElement>) => { dragState.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }, }, rest, ), });}DragTrigger.displayName = "FloatingPanelDragTrigger";
function Title(allProps: useRender.ComponentProps<"h2"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const panel = usePanel(); const sx = stylex.props( ...([palette, floatingPanelTitle.base, chrome.title, style] as unknown as ReadonlyArray<never>), ); return useRender({ defaultTagName: "h2", render: render as useRender.RenderProp, props: mergeProps( { id: panel.titleId, className: sx.className, style: sx.style }, rest, ), });}Title.displayName = "FloatingPanelTitle";
function Header(allProps: useRender.ComponentProps<"header"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const sx = stylex.props( ...([palette, floatingPanelHeader.base, style] as never[]), ); return useRender({ defaultTagName: "header", render: render as useRender.RenderProp, props: mergeProps({ className: sx.className, style: sx.style }, rest), });}Header.displayName = "FloatingPanelHeader";
function Body(allProps: useRender.ComponentProps<"div"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const sx = stylex.props(...([palette, floatingPanelBody.base, style] as never[])); return useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps({ className: sx.className, style: sx.style }, rest), });}Body.displayName = "FloatingPanelBody";
function CloseTrigger(allProps: useRender.ComponentProps<"button"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const panel = usePanel(); const sx = stylex.props( ...([palette, chrome.closeTrigger, style] as unknown as ReadonlyArray<never>), ); return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, props: mergeProps( { type: "button" as const, onClick: () => panel.setOpen(false), className: sx.className, style: sx.style, }, rest, ), });}CloseTrigger.displayName = "FloatingPanelCloseTrigger";
export const FloatingPanel = { Root, Trigger, Positioner, Content, Header, DragTrigger, Title, Body, CloseTrigger,};