Tailwind CSS: @custom-variant
for Drag and Drop Styling
by Christoph Schiessl on JavaScript and Tailwind CSS
Creating intuitive drag and drop interfaces requires clear visual feedback to guide users. While Tailwind CSS provides many built-in variants like :hover
and :focus
, there's no native variant for drag-over states. Fortunately, Tailwind's custom variant feature lets us create exactly what we need.
In this article, you'll learn how to create a custom drag-over
variant that applies styles when files are being dragged over a drop zone, complete with the JavaScript logic needed to make it work reliably.
Custom Variant Definition
First, let's define our custom variant in the Tailwind configuration. The syntax is straightforward:
@custom-variant drag-over {
&.drag-over { @slot }
}
This creates a variant that activates when an element has the drag-over
class. You can now use it with any Tailwind utility class:
<div class="drag-over:bg-blue-100 drag-over:border-blue-300 drag-over:border-dashed">
Drop files here
</div>
The @slot
placeholder gets replaced with the actual utility styles during compilation. When the drag-over
class is present, the background becomes light blue, the border turns blue and dashed.
JavaScript Implementation
The real magic happens in JavaScript, where we manage the drag-over
class based on drag events. Here's the complete implementation:
const counters = {};
document.body.addEventListener('dragenter', function (event) {
event.preventDefault();
const dropZone = event.target.closest('.drop-zone');
const dropZoneId = dropZone?.id;
if (dropZone !== null && dropZoneId !== undefined) {
const count = (counters[dropZoneId] || 0) + 1;
counters[dropZoneId] = count;
if (count > 0) { dropZone.classList.add('drag-over'); }
}
});
document.body.addEventListener('dragover', function (event) {
event.preventDefault();
});
document.body.addEventListener('dragleave', function (event) {
event.preventDefault();
const dropZone = event.target.closest('.drop-zone');
const dropZoneId = dropZone?.id;
if (dropZone !== null && dropZoneId !== undefined) {
const count = (counters[dropZoneId] || 1) - 1;
counters[dropZoneId] = count;
if (count === 0) { dropZone.classList.remove('drag-over'); }
}
});
Let's break down what's happening here:
The counters
object is crucial for reliable drag-over detection. Here's why: drag events fire for every element the mouse passes over, including child elements inside your drop zone.
Without counters, dragging over a child element would trigger dragleave
on the parent (removing the visual feedback), immediately followed by dragenter
on the child (adding it back). This creates flickering as styles rapidly toggle on and off.
By counting dragenter
and dragleave
events, we only remove the drag-over
class when all nested elements have been left (count reaches zero). This ensures stable visual feedback throughout the drag operation.
Event Handler Details
dragenter
— Fires when the dragged item enters an element. We increment the counter and add thedrag-over
class on the first entry.dragover
— Fires continuously while dragging over an element. We prevent the default behavior to allow dropping.dragleave
— Fires when leaving an element. We decrement the counter and only remove the class when reaching zero.
Each handler uses event.target.closest('.drop-zone')
to find the nearest drop zone ancestor, allowing for flexible HTML structures with nested elements.
Complete HTML Example
Here's a working example that demonstrates the custom variant in action:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drag and Drop with Custom Tailwind Variants</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@custom-variant drag-over {
&.drag-over { @slot }
}
</style>
</head>
<body class="p-8 bg-gray-50">
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-3xl font-bold text-gray-900">File Drop Zones</h1>
<div id="zone1"
class="drop-zone h-48 border-2 border-gray-300 border-dashed rounded-lg
flex items-center justify-center text-gray-500 transition-all duration-200
drag-over:bg-blue-50 drag-over:border-blue-400 drag-over:text-blue-600">
<div class="text-center">
<p class="text-lg font-medium">Drop Zone 1</p>
<p class="text-sm">Drag files here</p>
</div>
</div>
<div id="zone2"
class="drop-zone h-48 border-2 border-gray-300 border-dashed rounded-lg
p-6 text-gray-500 transition-all duration-200
drag-over:bg-purple-50 drag-over:border-purple-400 drag-over:text-purple-600">
<h3 class="text-lg font-medium mb-2">Drop Zone 2 with Nested Content</h3>
<p class="mb-4">This zone contains multiple child elements to demonstrate why counters are necessary.</p>
<div class="bg-white p-4 rounded border">
<p class="text-sm">Nested content that could trigger unwanted events</p>
<button class="mt-2 px-3 py-1 bg-blue-500 text-white rounded text-sm">
Button inside drop zone
</button>
</div>
</div>
</div>
<script>
const counters = {};
document.body.addEventListener('dragenter', function (event) {
event.preventDefault();
const dropZone = event.target.closest('.drop-zone');
const dropZoneId = dropZone?.id;
if (dropZone !== null && dropZoneId !== undefined) {
const count = (counters[dropZoneId] || 0) + 1;
counters[dropZoneId] = count;
if (count > 0) { dropZone.classList.add('drag-over'); }
}
});
document.body.addEventListener('dragover', function (event) {
event.preventDefault();
});
document.body.addEventListener('dragleave', function (event) {
event.preventDefault();
const dropZone = event.target.closest('.drop-zone');
const dropZoneId = dropZone?.id;
if (dropZone !== null && dropZoneId !== undefined) {
const count = (counters[dropZoneId] || 1) - 1;
counters[dropZoneId] = count;
if (count === 0) { dropZone.classList.remove('drag-over'); }
}
});
// Optional: Handle the actual drop event
document.body.addEventListener('drop', function (event) {
event.preventDefault();
const dropZone = event.target.closest('.drop-zone');
const dropZoneId = dropZone?.id;
if (dropZone !== null && dropZoneId !== undefined) {
dropZone.classList.remove('drag-over');
counters[dropZoneId] = 0;
// Handle the dropped files here
console.log('Files dropped on', dropZoneId, event.dataTransfer.files);
}
});
</script>
</body>
</html>
Observations
Firstly, each drop zone needs a unique id
attribute for the counter system to work correctly. Without unique IDs, the counters would interfere with each other.
Secondly, the custom variant is very flexible and works with any Tailwind utility. You can create sophisticated hover effects:
<div class="drop-zone
drag-over:scale-105
drag-over:shadow-xl
drag-over:ring-4
drag-over:ring-blue-200">
Thirdly, the event listeners are attached to document.body
to handle drag events through event bubbling. This provides additional flexibility, because it also captures the events from drop zones that were rendered through JavaScript as would be the case in an SPA.
Wrapping Up
Custom Tailwind variants provide a clean, maintainable way to handle dynamic styling needs. By combining the @custom-variant
directive with thoughtful JavaScript event handling, you can create smooth drag-and-drop experiences that feel native to your design system.
The counter-based approach ensures reliable visual feedback even with complex nested HTML structures, while Tailwind's utility classes keep the styling predictable and easy to modify.
Try experimenting with different color combinations and animations to create drop zones that perfectly match your application's visual identity.