controls

controls

Define which areas can initiate dragging

The controls plugin defines which parts of an element can be used to start dragging. Think modal headers, card handles, or toolbar grips - only specific areas should be draggable while the rest remains interactive.

controls({ allow: ControlFrom.selector('.handle') }); // Only handle can drag
controls({ block: ControlFrom.selector('.button') }); // Buttons can't drag
controls({
  allow: ControlFrom.selector('.header'),
  block: ControlFrom.selector('.close'),
}); // Header drags, close button doesn't

Basic Usage

<script>
  import { controls, ControlFrom } from '@neodrag/svelte';
</script>

<!-- Only header can initiate dragging -->
<div
  {@attach draggable([
    controls({ allow: ControlFrom.selector('.header') }),
  ])}
>
  <div class="header">Drag handle</div>
  <div class="content">Content (not draggable)</div>
</div>

<!-- Anywhere except buttons -->
<div
  {@attach draggable([
    controls({ block: ControlFrom.selector('button') }),
  ])}
>
  <p>Drag anywhere in this area</p>
  <button>Can't drag from here</button>
</div>

Complex Control Scenarios

<script>
  import {
    controls,
    ControlFrom,
    bounds,
    BoundsFrom,
  } from '@neodrag/svelte';
</script>

<div
  {@attach draggable([
    controls({
      allow: ControlFrom.selector('.modal-header'),
      block: ControlFrom.selector('.modal-close, .modal-body button'),
    }),
    bounds(BoundsFrom.viewport()),
  ])}
>
  <div class="modal-header">
    <h3>Modal Title</h3>
    <button class="modal-close">×</button>
  </div>
  <div class="modal-body">
    <p>Modal content</p>
    <button>Action Button</button>
  </div>
</div>

Dynamic Control Zones

For controls that change during interaction, specify when to recalculate zones:

// Recompute zones on every drag event (for dynamic UIs)
controls(
  {
    allow: ControlFrom.selector('.handle'),
    block: ControlFrom.selector('.disabled'),
  },
  (ctx) => ctx.hook === 'drag',
);

// Only recompute at drag start (better performance)
controls(
  {
    allow: ControlFrom.selector('.handle'),
  },
  (ctx) => ctx.hook === 'start',
);

// Recompute during setup and start
controls(
  {
    allow: ControlFrom.selector('.handle'),
  },
  (ctx) => ctx.hook === 'setup' || ctx.hook === 'start',
);

Conditional Control Zones

controls(
  {
    allow: (root) => {
      if (isEditMode) {
        return ControlFrom.selector('.edit-handle')(root);
      } else {
        return ControlFrom.selector('.view-handle')(root);
      }
    },
  },
  (ctx) => {
    // Recompute when mode changes
    return ctx.hook === 'setup' || ctx.hook === 'start';
  },
);

Context-Aware Controls

controls(
  {
    allow: ControlFrom.selector('.handle'),
    block: (root) => {
      // Block controls based on current state
      return ControlFrom.selector(
        isCtrlPressed ? '.ctrl-block' : '.normal-block',
      )(root);
    },
  },
  (ctx) => {
    // Recompute on key events during drag
    return ctx.hook === 'drag';
  },
);

How It Works

The controls plugin manages drag initiation permissions:

  1. Finds control zones on setup by querying the DOM
  2. Recomputes zones based on shouldRecompute function during different hooks
  3. Checks click position in shouldStart hook
  4. Determines permission based on which zones contain the click point
  5. Returns boolean to allow/prevent drag start

Zone recomputation: Control zones are recalculated when shouldRecompute returns true for the current hook.

Zone priority logic:

  • If allow zones exist and click is outside all of them → block
  • If click is in both allow and block zones → use priority option
  • Smaller (nested) zones take precedence over larger ones

Performance Considerations

When using dynamic control zones:

// ❌ Expensive - queries DOM on every drag event
controls(
  {
    allow: ControlFrom.selector('.complex-selector:nth-child(odd)'),
  },
  (ctx) => ctx.hook === 'drag',
);

// ✅ Better - cache zones, only recompute when needed
controls(
  {
    allow: ControlFrom.selector('.handle'),
  },
  (ctx) => {
    // Only recompute if DOM structure might have changed
    return ctx.hook === 'setup' || ctx.hook === 'start';
  },
);

// ✅ Best - static zones for better performance
controls({
  allow: ControlFrom.selector('.handle'),
}); // Uses default setup-only recomputation

API Reference

function controls(
  options?: {
    allow?: ReturnType<
      typeof ControlFrom.selector | typeof ControlFrom.elements
    >;
    block?: ReturnType<
      typeof ControlFrom.selector | typeof ControlFrom.elements
    >;
    priority?: 'allow' | 'block';
  } | null,
  shouldRecompute?: (ctx: {
    hook: 'setup' | 'start' | 'drag' | 'end';
  }) => boolean,
): Plugin;

Parameters:

  • options - Control configuration object
    • allow - Elements that can initiate dragging
    • block - Elements that cannot initiate dragging
    • priority - Which takes precedence in overlapping zones (‘allow’ | ‘block’)
  • shouldRecompute - When to recalculate control zones. Receives a context object with the current hook name.
    • Default: (ctx) => ctx.hook === 'setup' (recompute on plugin initialization only)
    • 'setup' - Plugin initialization
    • 'start' - Drag start
    • 'drag' - During dragging
    • 'end' - Drag end

ControlFrom utilities:

  • ControlFrom.selector(cssSelector) - Elements matching CSS selector
  • ControlFrom.elements(nodeList) - Specific DOM elements

Returns: A plugin object for use with draggable.