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

  1. dragenter — Fires when the dragged item enters an element. We increment the counter and add the drag-over class on the first entry.
  2. dragover — Fires continuously while dragging over an element. We prevent the default behavior to allow dropping.
  3. 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.

Christoph Schiessl

Hi, I'm Christoph Schiessl.

I help you build robust and fast Web Applications.


I'm available for hire as a freelance web developer, so you can take advantage of my more than a decade of experience working on many projects across several industries. Most of my clients are building web-based SaaS applications in a B2B context and depend on my expertise in various capacities.

More often than not, my involvement includes hands-on development work using technologies like Python, JavaScript, and PostgreSQL. Furthermore, if you already have an established team, I can support you as a technical product manager with a passion for simplifying complex processes. Lastly, I'm an avid writer and educator who takes pride in breaking technical concepts down into the simplest possible terms.

Continue Reading?

Here are a few more Articles for you ...


The Built-In all() Function

Learn how to use the built-in all() function in Python for boolean logic, with examples and different implementations.

By Christoph Schiessl on Python

The Built-In any() Function

Learn how to use the built-in any() function in Python to determine if any element in an iterable is True, with implementation and performance insights.

By Christoph Schiessl on Python

Detecting Exit Intent with Vanilla JavaScript

Use event handlers to detect exit intent so you can react to grab your website visitors' attention before leaving and perhaps convert them into repeat visitors.

By Christoph Schiessl on JavaScript

Web App Reverse Checklist

Ready to Build Your Next Web App?

Get my Web App Reverse Checklist first ...


Software Engineering is often driven by fashion, but swimming with the current is rarely the best choice. In addition to knowing what to do, it's equally important to know what not to do. And this is precisely what my free Web App Reverse Checklist will help you with.

Subscribe below to get your free copy of my Reverse Checklist delivered to your inbox. Afterward, you can expect one weekly email on building resilient Web Applications using Python, JavaScript, and PostgreSQL.

By the way, it goes without saying that I'm not sharing your email address with anyone, and you're free to unsubscribe at any time. No spam. No commitments. No questions asked.