Drag previews
How to control what is under the users pointer while a drag is occurringA drag preview is the thing that a user drags around during a drag operation. We have a number of supported techniques for controlling what the drag preview looks like.
Native drag previews
We recommend using native drag previews where possible as they have great performance characteristics (they are not rendered on the main thread) and they can be dragged between applications
Browsers have built in "native" mechanisms for rendering a drag preview
There are a few techniques you can use to control what a native drag preview will look like:
Approach 1: Use a custom native drag preview
You can ask the browser to take a photo of another visible element on the page and use that as the drag preview. There are some design constraints when leveraging native drag previews.
There are lots of platform gotchas when working with custom native drag previews. We recommend using
our setCustomNativeDragPreview() as it makes it safe and easy to work with custom native drag
previews.
Mounting a new element with setCustomNativeDragPreview
You can use setCustomNativeDragPreview to mount a new element to the page to be used as the drag
preview. setCustomNativeDragPreview will call your cleanup function to remove the preview
element from the page after the browser has taken a photo of the element.
setCustomNativeDragPreview adds the container Element to the document.body and will remove
the container Element after your cleanup function is called.
setCustomNativeDragPreview has been designed to work with any view abstraction.
Note: you are welcome to use the
onGenerateDragPreview | nativeSetDragImage, (opens new window)
API directly. However, we recommend you use setCustomNativeDragPreview as it covers over a number
of gotchas.
Usage example: react portals
This technique requires your component to be re-rendered, but maintains the current react
context
type State =
| {
type: 'idle';
}
| {
type: 'preview';
container: HTMLElement;
};
function Item() {
const [state, setState] = useState<State>({ type: 'idle' });
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
invariant(ref.current);
return draggable({
element: ref.current,
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
render({ container }) {
// Cause a `react` re-render to create your portal synchronously
setState({ type: 'preview', container });
// In our cleanup function: cause a `react` re-render to create remove your portal
// Note: you can also remove the portal in `onDragStart`,
// which is when the cleanup function is called
return () => setState({ type: 'idle' });
},
nativeSetDragImage,
});
},
});
}, []);
return (
<>
<div ref={ref}>Drag Me</div>
{state.type === 'preview' ? ReactDOM.createPortal(<Preview />, state.container) : null}
</>
);
}Usage example: A new react application
This technique requires no re-rendering of your component, but does not maintain the current react
context
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
draggable({
element: myElement,
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render({ container }) {
ReactDOM.render(<Preview item={item} />, container);
return function cleanup() {
ReactDOM.unmountComponentAtNode(container);
};
},
nativeSetDragImage,
});
},
});Usage example: plain JavaScript
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
draggable({
element: myElement,
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render({ container }) {
// Create our preview element
const preview = document.createElement('div');
// Populate and style the preview element however you like
preview.textContent = 'My Preview';
Object.assign(preview.style, {
padding: '20px',
backgroundColor: 'lightpink',
});
// put the "preview" element into the container element
container.appendChild(preview);
},
nativeSetDragImage,
});
},
});Positioning the drag preview
You can control where the custom native drag preview is placed by using the getOffset() argument.
You can return an {x: number, y: number} object from getOffset() which will control where the
native drag preview is rendered relative to the users pointer. {x: 0, y: 0} represents having the
users pointer user the top left corner of the drag preview.
For clarity:
const rect = container.getBoundingClientRect()
{x: 0, y: 0}→ top left of thecontainerwill be under the users pointer (default){x: rect.width, y: 0}top right of thecontainerwill be under the users pointer{x: rect.width, y: rect.height}bottom right of thecontainerwill be under the users pointer{x: 0, y: rect.height}bottom left of thecontainerwill be under the users pointer
type GetOffsetFn = (args: { container: HTMLElement }) => {
x: number;
y: number;
};Notes:
GetOffsetFnneeds to returnxandyas numbers as that is what the platform requires- You cannot use negative values (not supported by browsers). If you want to push the drag preview
away from the users pointer, use
pointerOutsideOfPreview(see below) - The max offset value for an axis is the
border-box. Values greater than theborder-boxget trimmed to be theborder-boxvalue getOffsetis called in the nextmicrotask, (opens new window) aftersetCustomNativeDragPreview:render. This helps ensure that the drag preview element has finished rendering into thecontainerbeforegetOffsetis called. Some frameworks likereact@18won't render the element to be used for the drag preview into thecontaineruntil the nextmicrotask.
{x: rect.width + 1, y: rect.height + 1} effectively becomes {x: rect.width, y: rect.height}.
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
draggable({
element: myElement,
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
// place the (near) top middle of the `container` under the users pointer
getOffset: () => {
const rect = container.getBoundingClientRect();
return { x: rect.width / 2, y: 16 };
},
render({ container }) {
ReactDOM.render(<Preview item={item} />, container);
return function cleanup() {
ReactDOM.unmountComponentAtNode(container);
};
},
nativeSetDragImage,
});
},
});We have getOffset() helpers for setCustomnativeDragPreview():
centerUnderPointer: centers the custom native drag preview under the users cursor
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer';
draggable({
element: myElement,
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: centerUnderPointer,
render({ container }) {
/* ... */
},
nativeSetDragImage,
});
},
});pointerOutsideOfPreview: a cross browser mechanism to push the drag preview in front of the users pointer.
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview';
draggable({
element: myElement,
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
// `x` and `y` can be any CSS value
getOffset: pointerOutsideOfPreview({
x: '8px',
y: 'calc(var(--grid) * 2)',
}),
render({ container }) {
/* ... */
},
nativeSetDragImage,
});
},
});When in left to right (ltr) languages, the drag preview is pushed forward, to the right of the
users pointer. For right to left (rtl) languages, the drag preview is also pushed forward, and the
preview will be on the left hand side of the users pointer. The direction (based on the dir
attribute) will be looked up on the container element; so you can control the dir by setting it
on the body; or on the container element itself.
onGenerateDragPreview({ nativeSetDragImage, source, location }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({
x: token('space.200'),
y: token('space.100'),
}),
render({ container }) {
// Forcing the direction for the container.
container.dir = 'ltr';
// By default, the `dir` inherited on `container` (which is a child of `body`) will be used.
},
});
},If you are using css variables inside of your getOffset() you need to be sure your css variables
are available at the <body> element, as the container is temporarily mounted as a child of
<body>.
On iOS, iPad and Android pointerOutsideOfPreview will center the drag preview under the users
pointer. Browsers on iOS, iPadOS and Android put the center of the drag preview under the users
pointer during the drag, even when we try to "push" the drag preview away from the pointer.
preserveOffsetOnSource: applies the initial cursor offset to the custom native drag preview for a seamless experience
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
draggable({
element: myElement,
onGenerateDragPreview: ({ nativeSetDragImage, location, source }) => {
setCustomNativeDragPreview({
getOffset: preserveOffsetOnSource({
element: source.element,
input: location.current.input,
}),
render({ container }) {
/* ... */
},
nativeSetDragImage,
});
},
});Notes:
- This helper works best when the rendered preview has the same dimensions as the dragged element
- On Android the center of the drag preview is always under the users pointer (platform limitation)
- On iOS and iPadOS, the drag preview location will initially match the
source, but during the drag the native preview will perform an animated slide so that it is centered on the users pointer. The centering of the drag preview during the drag on the users pointer is iOS and iPad platform behaviour.
Gotcha: CSS transforms
When creating custom drag preview element with setCustomNativeDragPreview, there is mixed support
for applying CSS transforms to the drag preview element.
| Scale | Rotate | Translate (avoid) | |
|---|---|---|---|
Chrome (114.0) | |||
Firefox (115.0) | |||
Safari (16.5.2) | (broken) | (broken) | (broken) |
Avoid using translate for positioning a drag preview (or pushing it away from the cursor). Please
use setCustomNativeDragPreview > getOffset for that (see above)
You can use CSS transforms as a progressive enhancement. For Chrome and Firefox you can use CSS transforms, but for Safari you cannot. You will need do a browser check for Safari, and only add CSS transforms to your preview element when the browser is not Safari.
const isSafari: boolean =
navigator.userAgent.includes('AppleWebKit') && !navigator.userAgent.includes('Chrome');
const transformStyles = css({
transform: 'scale(4deg)',
});
function Preview() {
return <div css={isSafari ? transformStyles : undefined}>Drag preview</div>;
}Approach 2: Change the appearance of a draggable
This approach has the best performance characteristics, but is subject to a number of gotchas. For
most consumers we recommend using setCustomNativeDragPreview
If you do nothing, then the browser will use a picture of the draggable element as the drag
preview. By leveraging event timings you can control the appearance of the drag preview that the
browser generates:
in
onGenerateDragPreviewmake whatever visual changes you want to thedraggableelement and those changes will be captured in the drag previewin
onDragStart:2a. revert changes of step 1. The user will never see the
draggableelement with the styles applied inonGenerateDragPreviewdue to paint timings2b. apply visual changes to the
draggableelement to make it clear to the user what element is being draggedin
onDropremove any visual changes you applied to thedraggableelement during the drag
There are a few constraints imposed by browsers that you need to follow if you want to use this technique:
- Your
draggableneeds to be completely visible and unobfiscated at the start of the drag. This can involve insuring that yourdraggableis not cut off by scroll (seescrollJustEnoughIntoView), and has no layers currently on top of thedraggable(for example, you might need to close some popups) - The users pointer still needs to be over the
draggableafter the changes you make to thedraggableelement inonGenerateDragPreview. Generally this means that you should not be changing the dimensions of thedraggableelement. - Avoid CSS
transformon yourdraggable. In Safari, CSStransforms that impact adraggablecan mess up native drag previews.- Bug 1, (opens new window) when a
transformimpacts adraggablebefore a drag starts: - Bug 2, (opens new window) when CSS
transformis applied to adraggableelement inonGenerateDragPreview
- Bug 1, (opens new window) when a
Non-native custom drag previews
In some situations, you might want to completely disable the native drag preview and render your own drag preview. The advantage of this technique is that you can update the drag preview during a drag. The downsides of this approach is that it is not as fast, and you cannot drag the non-native drag preview outside of a browser window.
To use this technique:
- disable the native drag preview
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
draggable({
element: myElement,
onGenerateDragPreview({ nativeSetDragPreview }) {
disableNativeDragPreview({ nativeSetDragPreview });
},
});This technique renders a
1x1transparent image as the native drag preview. There are a few alternative techniques for hiding the drag preview, but this technique yielded the best results across many browsers and devices.
- render your own element in
onDragStart(ideally in a portal), and under the user's pointer (you can uselocation.initial.inputto get the users initial position) - move the new element around in response to
onDragevents (uselocation.current.inputto get the users current pointer position) - remove the new element in
onDrop
If you are doing this technique, you will likely want to use the
preventUnhandled utility. Using that
addon will prevent the strange situation where when the user does not drop on a drop target there is
a fairly large pause before the drop event. This is because the browser does a drop animation when
the user does not drop on a drop target; a "return home" animation. Because you have hidden the
native drag preview, the user won't see this return home drop animation, but will experience a
delay. Using the
preventUnhandled utility ensures
that the return home drop animation won't run
No drag preview
For some experiences you might not want any drag preview (for example, resizing). All you need to do is disable the native drag preview and you are good to go.
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
draggable({
element: myElement,
onGenerateDragPreview({ nativeSetDragPreview }) {
disableNativeDragPreview({ nativeSetDragPreview });
},
});scrollJustEnoughIntoView
A little utility to quickly scroll something into view before a drag preview is captured. This is
helpful if you are leveraging default drag previews (ie not using setCustomNativeDragPreview). If
the draggable element is not completely in view, then the drag preview can be cut off.
import { scrollJustEnoughIntoView } from '@atlaskit/pragmatic-drag-and-drop/element/scroll-just-enough-into-view';
draggable({
element: myElement,
onGenerateDragPreview({ source }) {
scrollJustEnoughIntoView({ element: source.element });
},
});