DraggablePane

A toggle button and the floating panel it opens. Drag it by the grip on its tab, resize it from the gutter along its right and bottom edges, and reset it back under the toggle once you have moved it. It composes three attachments rather than reimplementing them: draggable moves it, resizable sizes it, and click_outside takes it away.

Use it for anything that hangs off a control in the corner of a widget — export options, plot controls, a settings panel — where a modal would be too heavy and a dropdown too small.

svelte<script lang="ts">
  import DraggablePane from '$lib/DraggablePane.svelte'
  import { Toggle } from '$lib'

  let show = $state(false)
  let persistent = $state(false)
  let resize = $state<`both` | `width` | `height` | `none`>(`both`)
  let last_close = $state(``)
</script>

<div style="display: flex; gap: 2em; align-items: center; flex-wrap: wrap">
  <label>
    Resize
    <select bind:value={resize}>
      {#each [`both`, `width`, `height`, `none`] as mode (mode)}<option>{mode}</option
        >{/each}
    </select>
  </label>
  <label>Persistent <Toggle bind:checked={persistent} /></label>
  {#if last_close}<small>last closed via <code>{last_close}</code></small>{/if}
</div>

<div
  style="position: relative; height: 22em; margin: 1em 0; padding: 6pt; border: 1px dashed gray; border-radius: 5pt"
>
  <div style="display: flex; justify-content: flex-end">
    <DraggablePane
      bind:show
      {persistent}
      {resize}
      on_close={({ via }) => (last_close = via)}
      toggle_props={{ title: `Pane options` }}
    >
      {#snippet children({ has_been_dragged, dragging })}
        <h4 style="margin: 0">Pane options</h4>
        <label>Name <input placeholder="type something" /></label>
        <label>Amount <input type="range" /></label>
        <p style="margin: 0; font-size: 0.85em; opacity: 0.7">
          dragged: {has_been_dragged} · dragging: {dragging}
        </p>
      {/snippet}
    </DraggablePane>
  </div>
</div>

The children snippet receives { show, show_controls, has_been_dragged, dragging }, so content can react to the pane’s own chrome — pausing an animation while the pane is being dragged over it, for instance.

The pane carries toc-exclude, so headings in its content stay out of a page’s Toc — a pane is floating chrome, not page structure.

Custom toggle content

open_icon and closed_icon pick from the icons this library bundles. For anything else — your own SVG, a label, an icon set we don’t ship — the toggle snippet replaces the button’s content while the button, and with it the anchor geometry, aria-expanded and click handling, stays with the component. It receives the same state as children.

svelte<script lang="ts">
  import DraggablePane from '$lib/DraggablePane.svelte'
</script>

<div
  style="position: relative; height: 10em; padding: 6pt; border: 1px dashed gray; border-radius: 5pt; display: flex; justify-content: flex-end"
>
  <DraggablePane toggle_props={{ title: `Layer info` }}>
    {#snippet toggle({ show })}
      <span style="font-size: 0.8em; padding: 0 2pt">{show ? `Hide` : `Info`}</span>
    {/snippet}
    {#snippet children()}
      <p style="margin: 0">Text, an inline SVG, an icon from another set — anything.</p>
    {/snippet}
  </DraggablePane>
</div>

Persistent panes

persistent drops the press-outside dismissal only. Escape still closes the pane, as does the close button that appears on its tab once you move it, so there is always a way out. Escape is layered: a pane opened from inside a dialog closes before the dialog does.

Positioning

position="absolute" (the default) places the pane against the nearest positioned ancestor, so it scrolls with the page. position="fixed" measures against the viewport instead, which escapes an overflow: hidden ancestor and clamps the pane’s top edge so at least 180px of it stays reachable, capping --pane-viewport-clamp to the space that remains below.

svelte<script lang="ts">
  import DraggablePane from '$lib/DraggablePane.svelte'
</script>

<div
  style="height: 6em; overflow: hidden; border: 1px dashed gray; border-radius: 5pt; padding: 6pt; display: flex; justify-content: flex-end"
>
  <DraggablePane position="fixed" resize="height" max_width="320px">
    {#snippet children()}
      <p style="margin: 0">This pane escapes the clipped box its toggle lives in.</p>
    {/snippet}
  </DraggablePane>
</div>

Styling

Everything hangs off CSS custom properties on the pane: --pane-bg, --pane-border, --pane-border-radius, --pane-box-shadow, --pane-width, --pane-min-height, --pane-max-height, --pane-padding, --pane-gap, --pane-overflow-x, --pane-overflow-y, --pane-z-index, --pane-control-tab-z-index, plus --pane-toggle-* for the button. pane_props and toggle_props spread onto the two elements for anything else.

The pane also carries data-resize and data-dragging, so content underneath can react without a prop — [data-dragging='true'] canvas { pointer-events: none }, say. Both, plus role and aria-modal, are applied after the pane_props spread so they cannot be clobbered. aria-label is applied before it, so a page holding more than one pane names them apart with pane_props={{ 'aria-label': 'Structure controls' }}.