Skip to content
Brok UI

Loading…

No results

Drop Indicator

Open source

A drop-cursor line that marks where a dragged block will land, auto-tracking the nearest gap in the editor. Composed as <x-ui.editor.drop-indicator>; <x-ui.drop-indicator> stays as the compatibility tag.

Version
v1.2.2
Stability
stable
License
MIT
Related
Editor
Block Handle
Sortable

Preview

First block
Second block
Orientation
previews.components.drop-indicator.default.blade.php Blade
<div class="p-6">
    {{-- The drop indicator only appears during a real drag, so this preview
         forces it visible between two stacked blocks to show the affordance;
         inside an editor, drag a block by its <x-ui.editor.block-handle> grip. --}}
    <div class="relative mx-auto max-w-sm space-y-3">
        <div class="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
            {{ __('First block') }}
        </div>

        <x-ui.editor.drop-indicator
            x-init="$nextTick(() => { width = $el.parentElement?.clientWidth ?? 0; left = 0; top = 52; visible = true })"
        />

        <div class="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
            {{ __('Second block') }}
        </div>
    </div>
</div>
Horizontal Current
Vertical Current

Installation

terminal
php artisan ui:add drop-indicator

Note

This component ships an Alpine behavior module at resources/js/ui/drop-indicator.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './drop-indicator.js';

Registry contract

php artisan ui:add drop-indicator writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/editor/drop-indicator.blade.php
  • blade resources/views/components/ui/drop-indicator.blade.php
  • js resources/js/ui/drop-indicator.js
Registry dependencies
editor
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

drop-indicator.md
# Brok UI: Drop Indicator (`drop-indicator`)

A drop-cursor line that marks where a dragged block will land, auto-tracking the nearest gap in the editor. Composed as <x-ui.editor.drop-indicator>; <x-ui.drop-indicator> stays as the compatibility tag.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add drop-indicator
```

## Usage

```blade
<div class="p-6">
    {{-- The drop indicator only appears during a real drag, so this preview
         forces it visible between two stacked blocks to show the affordance;
         inside an editor, drag a block by its <x-ui.editor.block-handle> grip. --}}
    <div class="relative mx-auto max-w-sm space-y-3">
        <div class="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
            {{ __('First block') }}
        </div>

        <x-ui.editor.drop-indicator
            x-init="$nextTick(() => { width = $el.parentElement?.clientWidth ?? 0; left = 0; top = 52; visible = true })"
        />

        <div class="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
            {{ __('Second block') }}
        </div>
    </div>
</div>
```

## Props

- `orientation` (string, default `horizontal`) — Line orientation the indicator draws in: horizontal (default) or vertical.

## Use when

- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Showing a drop-cursor line between blocks while a block is being dragged inside <x-ui.editor>.

## Avoid when

- Do not use rich editor affordances for simple one-line or plain-text tasks.
- The drag target is not the block-level editor surface, such as a generic sortable list; use sortable's own drop feedback instead.

## Anti-patterns

- Rich editors for one-line or easily validated input

## Rules

- Use the `<brok:drop-indicator>` tag (or `<x-ui.drop-indicator>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/drop-indicator
- Registry JSON (files, props, contract): https://brokui.dev/r/open/drop-indicator.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
orientation string horizontal Line orientation the indicator draws in: horizontal (default) or vertical.

Slots

  • default —
  • x-ui.editor.drop-indicator — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

drop-indicator

Behavior

  • Follows the nearest gap the editor publishes during a block-handle drag, and also tracks dragover directly for hosts using native HTML5 drag events.
  • Stays mounted in the DOM at all times and toggles visibility with x-show, so it renders harmlessly, hidden, when no drag is in progress.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Rich authoring and editing

Create or revise structured rich content.

Use when

  • Use when users need rich structured authoring, annotation, or collaborative editing controls.
  • Showing a drop-cursor line between blocks while a block is being dragged inside <x-ui.editor>.

Avoid when

  • Do not use rich editor affordances for simple one-line or plain-text tasks.
  • The drag target is not the block-level editor surface, such as a generic sortable list; use sortable's own drop feedback instead.

Use instead

  • Textarea or plain field for simple content

Anti-patterns

  • Rich editors for one-line or easily validated input
Anatomy
root
Theming hooks
drop-indicator

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The indicator line is aria-hidden, since it is a purely visual drop-target cue with no independent meaning for assistive tech.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="drop-indicator-{{ $record->id }}">
    <div class="p-6">
        {{-- The drop indicator only appears during a real drag, so this preview
             forces it visible between two stacked blocks to show the affordance;
             inside an editor, drag a block by its <x-ui.editor.block-handle> grip. --}}
        <div class="relative mx-auto max-w-sm space-y-3">
            <div class="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
                {{ __('First block') }}
            </div>
    
            <x-ui.editor.drop-indicator
                x-init="$nextTick(() => { width = $el.parentElement?.clientWidth ?? 0; left = 0; top = 52; visible = true })"
            />
    
            <div class="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
                {{ __('Second block') }}
            </div>
        </div>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/editor/drop-indicator.blade.php Blade
@props([
    'orientation' => 'horizontal',
])

{{--
    Drop Indicator — the drop-cursor line shown between blocks while a block is
    dragged inside an <x-ui.editor>. The `uiDropIndicator()` behaviour layer
    (drop-indicator.js) follows the editor's pointer drag (the editor publishes
    the nearest gap through `editor-drop-indicator`) and, for hosts that drag
    with HTML5 drag events, tracks `dragover` on the editable region itself.

    Geometry (top / left / width, px) is driven reactively via :style — colour is
    never set inline; it stays in the token-based utility classes below. The
    element remains in the DOM at all times (x-show toggles visibility), so it is
    safe to render standalone: it simply stays hidden until a drag begins.

    Public API on the Alpine scope (for hosts that want to drive it manually):
        show({ top, left, width })   position + reveal the line
        hide()                       conceal the line
--}}
<div
    data-slot="drop-indicator"
    data-orientation="{{ $orientation }}"
    x-data="uiDropIndicator()"
    x-show="visible"
    x-cloak
    x-transition:enter="transition duration-100 ease-out motion-reduce:transition-none"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    aria-hidden="true"
    :style="{ top: top + 'px', insetInlineStart: left + 'px', width: width + 'px' }"
    {{ $attributes->merge(['class' => 'pointer-events-none absolute z-overlay flex -translate-y-1/2 items-center transition-[top] duration-100 ease-out motion-reduce:transition-none']) }}
>
    {{-- A 2px neutral (foreground) line with a soft halo, end-capped by small dots so the
         gap reads as a target, not a stray rule. Follows the gap with a short
         glide so it never jumps between blocks. --}}
    <span class="-ms-1 size-2 shrink-0 rounded-full bg-foreground ring-2 ring-foreground/20"></span>
    <span class="h-0.5 w-full rounded-full bg-foreground shadow-[0_0_0_2px] shadow-foreground/15"></span>
    <span class="-me-1 size-2 shrink-0 rounded-full bg-foreground ring-2 ring-foreground/20"></span>
</div>
resources/views/components/ui/drop-indicator.blade.php Blade
{{-- <x-ui.drop-indicator> is the install slug's compatibility tag: the part lives at
     <x-ui.editor.drop-indicator> (ui/editor/drop-indicator) with every other editor part.
     Compose the namespaced tag in new code; this file only forwards to it. --}}
@props([
    'orientation' => 'horizontal',
])

<x-ui.editor.drop-indicator :orientation="$orientation" {{ $attributes }} />
resources/js/ui/drop-indicator.js JS
/**
 * Drop Indicator behaviour layer (uiDropIndicator).
 *
 * A drop-cursor controller for the contenteditable <x-ui.editor>. While a block
 * is dragged, it tracks the nearest gap between the editor's block-level children
 * and exposes the line's geometry as reactive state the Blade :style binds to:
 *
 *   visible   reveal / conceal the line (x-show)
 *   top       offset (px) from the indicator's offsetParent
 *   left      inline-start offset (px)
 *   width     line width (px), matched to the target block
 *
 * It binds drag listeners to the editor's content region in init() and removes
 * every one of them in destroy(), so unmounting mid-drag can never leak.
 *
 * Public methods show(rect) / hide() let a host drive the line manually.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiDropIndicator', () => ({
        visible: false,
        top: 0,
        left: 0,
        width: 0,

        init() {
            // The editable region this indicator decorates. Prefer the sibling
            // content node inside the shared editor root; fall back to our parent
            // so a standalone instance still has a sane (inert) target.
            this.target =
                this.$el.closest('[data-slot="editor"]')?.querySelector('[data-slot="editor-content"]') ||
                this.$el.parentElement;

            // Keep bound references so destroy() can remove exactly what it added.
            this._onDragOver = (event) => this.onDragOver(event);
            this._onDragLeave = () => this.hide();
            this._onDrop = () => this.hide();
            this._onDragEnd = () => this.hide();

            // The editor's own pointer drag (block handle) publishes the gap it
            // would drop into; only the indicator inside that editor reacts.
            this._editor = this.$el.closest('[data-slot="editor"]');
            this._onEditorDrop = (event) => {
                if (this._editor && event.detail?.editor !== this._editor) return;
                if (event.detail?.rect) this.show(event.detail.rect);
                else this.hide();
            };
            window.addEventListener('editor-drop-indicator', this._onEditorDrop);

            if (this.target) {
                this.target.addEventListener('dragover', this._onDragOver);
                this.target.addEventListener('dragleave', this._onDragLeave);
                this.target.addEventListener('drop', this._onDrop);
                this.target.addEventListener('dragend', this._onDragEnd);
            }
        },

        destroy() {
            window.removeEventListener('editor-drop-indicator', this._onEditorDrop);
            if (this.target) {
                this.target.removeEventListener('dragover', this._onDragOver);
                this.target.removeEventListener('dragleave', this._onDragLeave);
                this.target.removeEventListener('drop', this._onDrop);
                this.target.removeEventListener('dragend', this._onDragEnd);
            }
        },

        /* --------------------------------------------------------------- public */

        /** Position the line at the given offset box and reveal it. */
        show(rect) {
            if (!rect) {
                return;
            }
            this.top = Math.round(rect.top || 0);
            this.left = Math.round(rect.left || 0);
            this.width = Math.round(rect.width || 0);
            this.visible = true;
        },

        /** Conceal the line. */
        hide() {
            this.visible = false;
        },

        /* ---------------------------------------------------------------- drag */

        onDragOver(event) {
            // Allow this region to be a valid drop target.
            event.preventDefault();
            const rect = this.nearestGap(event.clientY);
            if (rect) {
                this.show(rect);
            } else {
                this.hide();
            }
        },

        /**
         * Among the target's block-level children, find the one whose vertical
         * midpoint is closest to the cursor, then return an offset box (relative
         * to this indicator's offsetParent) for the gap edge nearest the cursor.
         */
        nearestGap(clientY) {
            if (!this.target) {
                return null;
            }
            const children = [...this.target.children];
            if (children.length === 0) {
                return null;
            }

            let best = null;
            let bestDistance = Infinity;
            for (const child of children) {
                const box = child.getBoundingClientRect();
                const midpoint = box.top + box.height / 2;
                const distance = Math.abs(clientY - midpoint);
                if (distance < bestDistance) {
                    bestDistance = distance;
                    best = { child, box, midpoint };
                }
            }
            if (!best) {
                return null;
            }

            // Snap to the gap above or below the nearest child.
            const edgeTop = clientY > best.midpoint ? best.box.bottom : best.box.top;

            // Convert the viewport edge into offsets relative to our offsetParent.
            const parent = this.$el.offsetParent || this.target.offsetParent || this.target;
            const parentBox = parent.getBoundingClientRect();

            return {
                top: edgeTop - parentBox.top,
                left: best.box.left - parentBox.left,
                width: best.box.width,
            };
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0