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.
Preview
<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>
Installation
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:
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.
-
resources/views/components/ui/editor/drop-indicator.blade.php -
resources/views/components/ui/drop-indicator.blade.php -
resources/js/ui/drop-indicator.js
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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
@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>
{{-- <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 }} />
/**
* 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