# Floating Panel

A draggable, resizable panel that floats above the page. It ships with the Indoku theme applied.

Always give `CloseTrigger` and `StageTrigger` children (icons or text); they render only what you pass.

Group: Overlays & Feedback

## Import

```tsx
import { FloatingPanel, useFloatingPanel } from "@indoku/ui"
```

## Anatomy

```text
FloatingPanel.Root (or RootProvider)
├─ FloatingPanel.Trigger
└─ Portal
   └─ FloatingPanel.Positioner
      └─ FloatingPanel.Content
         ├─ FloatingPanel.Header
         │  ├─ FloatingPanel.DragTrigger › FloatingPanel.Title
         │  └─ FloatingPanel.Control › StageTrigger / CloseTrigger
         ├─ FloatingPanel.Body
         └─ FloatingPanel.ResizeTriggers
```

## Examples

### Basic

Root, Positioner, Content, Header, Body and ResizeTriggers.

```tsx
function FloatingPanelBasic() {
  return (
    <FloatingPanel.Root defaultSize={{ width: 320, height: 200 }} minSize={{ width: 240, height: 140 }}>
      <FloatingPanel.Trigger asChild>
        <Button variant="outline">Open panel</Button>
      </FloatingPanel.Trigger>
      <Portal>
        <FloatingPanel.Positioner>
          <FloatingPanel.Content>
            <FloatingPanel.Header>
              <FloatingPanel.DragTrigger>
                <FloatingPanel.Title>Floating panel</FloatingPanel.Title>
              </FloatingPanel.DragTrigger>
              <FloatingPanel.Control>
                <FloatingPanel.CloseTrigger>
                  <LuX />
                </FloatingPanel.CloseTrigger>
              </FloatingPanel.Control>
            </FloatingPanel.Header>
            <FloatingPanel.Body>
              <Text>Drag the header to move it, pull an edge to resize.</Text>
            </FloatingPanel.Body>
            <FloatingPanel.ResizeTriggers />
          </FloatingPanel.Content>
        </FloatingPanel.Positioner>
      </Portal>
    </FloatingPanel.Root>
  )
}
```

### Controlled open

Drive `open` and `onOpenChange` yourself.

```tsx
function FloatingPanelControlled() {
  const [open, setOpen] = useState(false)
  return (
    <HStack>
      <Button variant="outline" onClick={() => setOpen((o) => !o)}>
        {open ? "Close" : "Open"} (controlled)
      </Button>
      <FloatingPanel.Root open={open} onOpenChange={(e) => setOpen(e.open)} defaultSize={{ width: 300, height: 160 }}>
        <Portal>
          <FloatingPanel.Positioner>
            <FloatingPanel.Content>
              <FloatingPanel.Header>
                <FloatingPanel.DragTrigger>
                  <FloatingPanel.Title>Controlled</FloatingPanel.Title>
                </FloatingPanel.DragTrigger>
                <FloatingPanel.Control>
                  <FloatingPanel.CloseTrigger>
                    <LuX />
                  </FloatingPanel.CloseTrigger>
                </FloatingPanel.Control>
              </FloatingPanel.Header>
              <FloatingPanel.Body>
                <Text>open = {String(open)}</Text>
              </FloatingPanel.Body>
            </FloatingPanel.Content>
          </FloatingPanel.Positioner>
        </Portal>
      </FloatingPanel.Root>
    </HStack>
  )
}
```

### Stage triggers

`minimized`, `maximized` and `default`.

```tsx
function FloatingPanelStages() {
  return (
    <FloatingPanel.Root defaultSize={{ width: 340, height: 200 }}>
      <FloatingPanel.Trigger asChild>
        <Button variant="outline">Open with stage triggers</Button>
      </FloatingPanel.Trigger>
      <Portal>
        <FloatingPanel.Positioner>
          <FloatingPanel.Content>
            <FloatingPanel.Header>
              <FloatingPanel.DragTrigger>
                <FloatingPanel.Title>Stages</FloatingPanel.Title>
              </FloatingPanel.DragTrigger>
              <FloatingPanel.Control>
                <FloatingPanel.StageTrigger stage="minimized">
                  <LuMinus />
                </FloatingPanel.StageTrigger>
                <FloatingPanel.StageTrigger stage="maximized">
                  <LuMaximize2 />
                </FloatingPanel.StageTrigger>
                <FloatingPanel.StageTrigger stage="default">
                  <LuMinimize2 />
                </FloatingPanel.StageTrigger>
                <FloatingPanel.CloseTrigger>
                  <LuX />
                </FloatingPanel.CloseTrigger>
              </FloatingPanel.Control>
            </FloatingPanel.Header>
            <FloatingPanel.Body>
              <Text>Minimize, maximize or restore the panel.</Text>
            </FloatingPanel.Body>
            <FloatingPanel.ResizeTriggers />
          </FloatingPanel.Content>
        </FloatingPanel.Positioner>
      </Portal>
    </FloatingPanel.Root>
  )
}
```

### Store provider

Create the store with `useFloatingPanel` and pass it to `RootProvider`.

```tsx
function FloatingPanelStore() {
  const panel = useFloatingPanel({ defaultSize: { width: 300, height: 170 } })
  return (
    <>
      <HStack wrap="wrap">
        <Button variant="outline" onClick={() => panel.setOpen(true)}>
          Open
        </Button>
        <Button variant="outline" onClick={() => panel.maximize()}>
          Maximize
        </Button>
        <Button variant="outline" onClick={() => panel.minimize()}>
          Minimize
        </Button>
        <Button variant="outline" onClick={() => panel.restore()}>
          Restore
        </Button>
        <Text fontSize="sm" color="fg.muted">
          open: {String(panel.open)}
        </Text>
      </HStack>
      <FloatingPanel.RootProvider value={panel}>
        <Portal>
          <FloatingPanel.Positioner>
            <FloatingPanel.Content>
              <FloatingPanel.Header>
                <FloatingPanel.DragTrigger>
                  <FloatingPanel.Title>Store provider</FloatingPanel.Title>
                </FloatingPanel.DragTrigger>
                <FloatingPanel.Control>
                  <FloatingPanel.CloseTrigger>
                    <LuX />
                  </FloatingPanel.CloseTrigger>
                </FloatingPanel.Control>
              </FloatingPanel.Header>
              <FloatingPanel.Body>
                <Text>Driven from outside via useFloatingPanel.</Text>
              </FloatingPanel.Body>
            </FloatingPanel.Content>
          </FloatingPanel.Positioner>
        </Portal>
      </FloatingPanel.RootProvider>
    </>
  )
}
```

### Boundary constraints

Keep the panel inside an element with `getBoundaryEl`.

```tsx
function FloatingPanelBoundary() {
  const boundary = useRef<HTMLDivElement>(null)
  return (
    <Box ref={boundary} position="relative" h="280px" borderWidth="1px" rounded="md" borderStyle="dashed" p="3">
      <Text fontSize="sm" color="fg.muted">
        The panel cannot leave this box.
      </Text>
      <FloatingPanel.Root
        defaultOpen
        defaultSize={{ width: 220, height: 130 }}
        getBoundaryEl={() => boundary.current}
        defaultPosition={{ x: 20, y: 60 }}
      >
        <FloatingPanel.Positioner>
          <FloatingPanel.Content>
            <FloatingPanel.Header>
              <FloatingPanel.DragTrigger>
                <FloatingPanel.Title>Bounded</FloatingPanel.Title>
              </FloatingPanel.DragTrigger>
            </FloatingPanel.Header>
            <FloatingPanel.Body>
              <Text>Try dragging me out.</Text>
            </FloatingPanel.Body>
            <FloatingPanel.ResizeTriggers />
          </FloatingPanel.Content>
        </FloatingPanel.Positioner>
      </FloatingPanel.Root>
    </Box>
  )
}
```

### Anchor positioning

Compute the starting position with `getAnchorPosition`.

```tsx
function FloatingPanelAnchor() {
  return (
    <FloatingPanel.Root
      defaultSize={{ width: 260, height: 140 }}
      getAnchorPosition={({ triggerRect }) =>
        triggerRect ? { x: triggerRect.x, y: triggerRect.y + triggerRect.height + 8 } : { x: 100, y: 100 }
      }
    >
      <FloatingPanel.Trigger asChild>
        <Button variant="outline">Opens below me</Button>
      </FloatingPanel.Trigger>
      <Portal>
        <FloatingPanel.Positioner>
          <FloatingPanel.Content>
            <FloatingPanel.Header>
              <FloatingPanel.DragTrigger>
                <FloatingPanel.Title>Anchored</FloatingPanel.Title>
              </FloatingPanel.DragTrigger>
              <FloatingPanel.Control>
                <FloatingPanel.CloseTrigger>
                  <LuX />
                </FloatingPanel.CloseTrigger>
              </FloatingPanel.Control>
            </FloatingPanel.Header>
            <FloatingPanel.Body>
              <Text>Positioned from the trigger rect.</Text>
            </FloatingPanel.Body>
          </FloatingPanel.Content>
        </FloatingPanel.Positioner>
      </Portal>
    </FloatingPanel.Root>
  )
}
```

## Props

### FloatingPanel.Root

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` |  | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Initial open state. |
| `onOpenChange` | `(details: { open: boolean }) => void` |  | Called when the panel opens or closes. |
| `onStageChange` | `(details: { stage: 'minimized' \| 'maximized' \| 'default' }) => void` |  | Called when the stage changes. |
| `defaultSize / size` | `{ width: number; height: number }` |  | Initial or controlled size. |
| `minSize / maxSize` | `{ width: number; height: number }` |  | Resize limits. |
| `defaultPosition / position` | `{ x: number; y: number }` |  | Initial or controlled position. |
| `getBoundaryEl` | `() => HTMLElement \| null` |  | Element the panel is constrained to. Defaults to the viewport. |
| `getAnchorPosition` | `(details: { triggerRect, boundaryRect }) => { x: number; y: number }` |  | Computes the position when the panel opens. |
| `draggable / resizable` | `boolean` | `true` | Turn dragging or resizing off. |
| `lockAspectRatio` | `boolean` | `false` | Keep the aspect ratio while resizing. |
| `persistRect` | `boolean` | `false` | Remember size and position between opens. |

### FloatingPanel.StageTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `stage` * | `'minimized' \| 'maximized' \| 'default'` |  | Stage this trigger switches to. |

### useFloatingPanel (store)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open / setOpen(open)` | `boolean / fn` |  | Read or set the open state. |
| `minimize() / maximize() / restore()` | `() => void` |  | Change the stage. |
| `setPosition(pos) / setSize(size)` | `fn` |  | Move or resize programmatically. |

`*` = required. Style props and HTML attributes are accepted too.

## Accessibility

- The title is wired to the content with `aria-labelledby`.
- Give icon-only triggers an `aria-label`.

## Notes

- Render `Positioner` inside a `Portal` so the panel is not clipped by overflow containers.
- With `getBoundaryEl`, the boundary should be `position: relative`.

## See also

- Dialog: https://ui.indoku.org/docs/dialog
- Popover: https://ui.indoku.org/docs/popover
