diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 6bcf3a3a..4d7ad032 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -980,6 +980,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/File.zig"),
@import("../webapi/FileList.zig"),
@import("../webapi/FileReader.zig"),
+ @import("../webapi/DataTransfer.zig"),
@import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),
diff --git a/src/browser/tests/data_transfer.html b/src/browser/tests/data_transfer.html
new file mode 100644
index 00000000..61ea259a
--- /dev/null
+++ b/src/browser/tests/data_transfer.html
@@ -0,0 +1,128 @@
+
+
+ Test DataTransfer Web API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/DataTransfer.zig b/src/browser/webapi/DataTransfer.zig
new file mode 100644
index 00000000..28bc78a6
--- /dev/null
+++ b/src/browser/webapi/DataTransfer.zig
@@ -0,0 +1,291 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const lp = @import("lightpanda");
+
+const js = @import("../js/js.zig");
+const Frame = @import("../Frame.zig");
+const Page = @import("../Page.zig");
+
+const File = @import("File.zig");
+const FileList = @import("FileList.zig");
+const DataTransferItem = @import("DataTransferItem.zig");
+const DataTransferItemList = @import("DataTransferItemList.zig");
+
+const Allocator = std.mem.Allocator;
+
+// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface
+//
+// The canonical drag-data-store: one ordered list of items (string- or
+// file-kind). `.items` is a live DataTransferItemList view; `.files` is a
+// FileList rebuilt from the file-kind items so the two stay in sync. Per v1
+// scope the store is always read/write (no event-phase mode gating).
+const DataTransfer = @This();
+
+pub fn registerTypes() []const type {
+ return &.{
+ DataTransfer,
+ DataTransferItem,
+ DataTransferItemList,
+ DataTransferItemList.Iterator,
+ };
+}
+
+_arena: Allocator,
+// Refcounted so the GC weak-finalizer (or page teardown) releases the pooled
+// arena exactly once; mirrors Blob's lifecycle.
+_rc: lp.RC(u32) = .{},
+_items: std.ArrayList(*DataTransferItem) = .{},
+_item_list: *DataTransferItemList,
+// FileList lives on the factory slab and is frame-tracked, so each File ref it
+// holds is released at frame teardown (same path as ``).
+_files: *FileList,
+_drop_effect: []const u8 = "none",
+_effect_allowed: []const u8 = "uninitialized",
+
+pub fn init(frame: *Frame) !*DataTransfer {
+ const arena = try frame.getArena(.medium, "DataTransfer");
+ errdefer frame.releaseArena(arena);
+
+ const fl = try frame._factory.create(FileList{});
+ try frame.trackFileList(fl);
+
+ const self = try arena.create(DataTransfer);
+ const list = try arena.create(DataTransferItemList);
+ self.* = .{
+ ._arena = arena,
+ ._item_list = list,
+ ._files = fl,
+ };
+ list.* = .{ ._data_transfer = self };
+ return self;
+}
+
+pub fn deinit(self: *DataTransfer, page: *Page) void {
+ page.releaseArena(self._arena);
+}
+
+pub fn acquireRef(self: *DataTransfer) void {
+ self._rc.acquire();
+}
+
+pub fn releaseRef(self: *DataTransfer, page: *Page) void {
+ self._rc.release(self, page);
+}
+
+// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-getdata
+// "text" and "url" are shorthands the spec maps onto MIME types.
+fn normalizeFormat(arena: Allocator, format: []const u8) ![]const u8 {
+ if (std.ascii.eqlIgnoreCase(format, "text")) {
+ return "text/plain";
+ }
+ if (std.ascii.eqlIgnoreCase(format, "url")) {
+ return "text/uri-list";
+ }
+ const buf = try arena.dupe(u8, format);
+ return std.ascii.lowerString(buf, buf);
+}
+
+pub fn getData(self: *const DataTransfer, format: []const u8, frame: *Frame) ![]const u8 {
+ const norm = try normalizeFormat(frame.call_arena, format);
+ for (self._items.items) |it| {
+ if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
+ return it._payload.string;
+ }
+ }
+ return "";
+}
+
+pub fn setData(self: *DataTransfer, format: []const u8, data: []const u8) !void {
+ const norm = try normalizeFormat(self._arena, format);
+ const dup = try self._arena.dupe(u8, data);
+ for (self._items.items) |it| {
+ if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
+ it._payload = .{ .string = dup };
+ return;
+ }
+ }
+ const it = try self._arena.create(DataTransferItem);
+ it.* = .{ ._kind = .string, ._type = norm, ._payload = .{ .string = dup } };
+ try self._items.append(self._arena, it);
+}
+
+pub fn clearData(self: *DataTransfer, format_: ?[]const u8, frame: *Frame) !void {
+ if (format_) |format| {
+ const norm = try normalizeFormat(frame.call_arena, format);
+ var i: usize = 0;
+ while (i < self._items.items.len) {
+ const it = self._items.items[i];
+ if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
+ _ = self._items.orderedRemove(i);
+ } else {
+ i += 1;
+ }
+ }
+ return;
+ }
+ // No format: remove every string item, leave file items in place.
+ var i: usize = 0;
+ while (i < self._items.items.len) {
+ if (self._items.items[i]._kind == .string) {
+ _ = self._items.orderedRemove(i);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+// --- DataTransferItemList delegation ---
+
+// add(File) -> file item ; add(DOMString, DOMString) -> string item.
+pub fn addItem(self: *DataTransfer, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem {
+ if (data.toZig(*File)) |file| {
+ return try self.addFileItem(file, frame);
+ } else |_| {}
+
+ const s = try data.toZig([]const u8);
+ const norm = try normalizeFormat(self._arena, type_ orelse "");
+ const dup = try self._arena.dupe(u8, s);
+ const it = try self._arena.create(DataTransferItem);
+ it.* = .{ ._kind = .string, ._type = norm, ._payload = .{ .string = dup } };
+ try self._items.append(self._arena, it);
+ return it;
+}
+
+fn addFileItem(self: *DataTransfer, file: *File, frame: *Frame) !*DataTransferItem {
+ file._proto.acquireRef();
+ const it = try self._arena.create(DataTransferItem);
+ it.* = .{ ._kind = .file, ._type = file._proto.getType(), ._payload = .{ .file = file } };
+ try self._items.append(self._arena, it);
+ try self.rebuildFiles(frame);
+ return it;
+}
+
+pub fn removeItem(self: *DataTransfer, index: u32, frame: *Frame) !void {
+ if (index >= self._items.items.len) {
+ return;
+ }
+ const it = self._items.orderedRemove(index);
+ if (it._kind == .file) {
+ it._payload.file._proto.releaseRef(frame._page);
+ try self.rebuildFiles(frame);
+ }
+}
+
+pub fn clearItems(self: *DataTransfer, frame: *Frame) !void {
+ for (self._items.items) |it| {
+ if (it._kind == .file) {
+ it._payload.file._proto.releaseRef(frame._page);
+ }
+ }
+ self._items.clearRetainingCapacity();
+ try self.rebuildFiles(frame);
+}
+
+// Rebuild the FileList slice from the current file-kind items, in order.
+fn rebuildFiles(self: *DataTransfer, frame: *Frame) !void {
+ var files: std.ArrayList(*File) = .{};
+ for (self._items.items) |it| {
+ if (it._kind == .file) {
+ try files.append(frame.arena, it._payload.file);
+ }
+ }
+ self._files._files = try files.toOwnedSlice(frame.arena);
+}
+
+// --- accessors ---
+
+pub fn getFiles(self: *DataTransfer) *FileList {
+ return self._files;
+}
+
+pub fn getItems(self: *DataTransfer) *DataTransferItemList {
+ return self._item_list;
+}
+
+pub fn getTypes(self: *DataTransfer, frame: *Frame) ![][]const u8 {
+ var out: std.ArrayList([]const u8) = .{};
+ var has_files = false;
+ for (self._items.items) |it| {
+ switch (it._kind) {
+ .string => try out.append(frame.call_arena, it._type),
+ .file => has_files = true,
+ }
+ }
+ if (has_files) {
+ try out.append(frame.call_arena, "Files");
+ }
+ return out.toOwnedSlice(frame.call_arena);
+}
+
+pub fn getDropEffect(self: *const DataTransfer) []const u8 {
+ return self._drop_effect;
+}
+
+pub fn setDropEffect(self: *DataTransfer, value: []const u8) !void {
+ inline for (.{ "none", "copy", "link", "move" }) |valid| {
+ if (std.mem.eql(u8, value, valid)) {
+ self._drop_effect = valid;
+ return;
+ }
+ }
+}
+
+pub fn getEffectAllowed(self: *const DataTransfer) []const u8 {
+ return self._effect_allowed;
+}
+
+pub fn setEffectAllowed(self: *DataTransfer, value: []const u8) !void {
+ inline for (.{ "none", "copy", "copyLink", "copyMove", "link", "linkMove", "move", "all", "uninitialized" }) |valid| {
+ if (std.mem.eql(u8, value, valid)) {
+ self._effect_allowed = valid;
+ return;
+ }
+ }
+}
+
+// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-setdragimage
+// No-op: Lightpanda has no rendered drag feedback.
+pub fn setDragImage(_: *DataTransfer, _: js.Value, _: i32, _: i32) void {}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DataTransfer);
+
+ pub const Meta = struct {
+ pub const name = "DataTransfer";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(DataTransfer.init, .{});
+ pub const dropEffect = bridge.accessor(DataTransfer.getDropEffect, DataTransfer.setDropEffect, .{});
+ pub const effectAllowed = bridge.accessor(DataTransfer.getEffectAllowed, DataTransfer.setEffectAllowed, .{});
+ pub const files = bridge.accessor(DataTransfer.getFiles, null, .{});
+ pub const items = bridge.accessor(DataTransfer.getItems, null, .{});
+ pub const types = bridge.accessor(DataTransfer.getTypes, null, .{});
+ pub const getData = bridge.function(DataTransfer.getData, .{});
+ pub const setData = bridge.function(DataTransfer.setData, .{});
+ pub const clearData = bridge.function(DataTransfer.clearData, .{});
+ pub const setDragImage = bridge.function(DataTransfer.setDragImage, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: DataTransfer" {
+ try testing.htmlRunner("data_transfer.html", .{});
+}
diff --git a/src/browser/webapi/DataTransferItem.zig b/src/browser/webapi/DataTransferItem.zig
new file mode 100644
index 00000000..2ee1eaa8
--- /dev/null
+++ b/src/browser/webapi/DataTransferItem.zig
@@ -0,0 +1,90 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const lp = @import("lightpanda");
+
+const js = @import("../js/js.zig");
+
+const File = @import("File.zig");
+
+const log = lp.log;
+
+// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitem-interface
+const DataTransferItem = @This();
+
+pub const Kind = enum { string, file };
+
+_kind: Kind,
+// For string items: the normalized format (e.g. "text/plain").
+// For file items: the File's MIME type.
+_type: []const u8,
+_payload: Payload,
+
+pub const Payload = union(Kind) {
+ string: []const u8,
+ file: *File,
+};
+
+pub fn getKind(self: *const DataTransferItem) []const u8 {
+ return switch (self._kind) {
+ .string => "string",
+ .file => "file",
+ };
+}
+
+pub fn getType(self: *const DataTransferItem) []const u8 {
+ return self._type;
+}
+
+pub fn getAsFile(self: *const DataTransferItem) ?*File {
+ return switch (self._payload) {
+ .file => |f| f,
+ .string => null,
+ };
+}
+
+// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransferitem-getasstring
+// v1 invokes the callback synchronously with the string value. File items and a
+// missing callback are no-ops, per spec.
+pub fn getAsString(self: *const DataTransferItem, cb_: ?js.Function) !void {
+ const cb = cb_ orelse return;
+ const s = switch (self._payload) {
+ .string => |str| str,
+ .file => return,
+ };
+ var caught: js.TryCatch.Caught = undefined;
+ cb.tryCall(void, .{s}, &caught) catch {
+ log.debug(.js, "getAsString callback", .{ .caught = caught, .source = "DataTransferItem" });
+ };
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DataTransferItem);
+
+ pub const Meta = struct {
+ pub const name = "DataTransferItem";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const kind = bridge.accessor(DataTransferItem.getKind, null, .{});
+ pub const @"type" = bridge.accessor(DataTransferItem.getType, null, .{});
+ pub const getAsFile = bridge.function(DataTransferItem.getAsFile, .{});
+ pub const getAsString = bridge.function(DataTransferItem.getAsString, .{});
+};
diff --git a/src/browser/webapi/DataTransferItemList.zig b/src/browser/webapi/DataTransferItemList.zig
new file mode 100644
index 00000000..a69af1ea
--- /dev/null
+++ b/src/browser/webapi/DataTransferItemList.zig
@@ -0,0 +1,104 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../js/js.zig");
+const Frame = @import("../Frame.zig");
+
+const DataTransfer = @import("DataTransfer.zig");
+const DataTransferItem = @import("DataTransferItem.zig");
+
+// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitemlist-interface
+//
+// A live view over the owning DataTransfer's item list; all mutations are
+// delegated to the DataTransfer so `.files` stays in sync.
+const DataTransferItemList = @This();
+
+_data_transfer: *DataTransfer,
+
+pub fn getLength(self: *const DataTransferItemList) u32 {
+ return @intCast(self._data_transfer._items.items.len);
+}
+
+pub fn item(self: *const DataTransferItemList, index: u32) ?*DataTransferItem {
+ const items = self._data_transfer._items.items;
+ if (index >= items.len) {
+ return null;
+ }
+ return items[index];
+}
+
+// add(DOMString data, DOMString type) | add(File data)
+// The overload is resolved by inspecting the first argument: a File yields a
+// file item (the `type` argument is ignored), anything else a string item.
+pub fn add(self: *DataTransferItemList, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem {
+ return self._data_transfer.addItem(data, type_, frame);
+}
+
+pub fn remove(self: *DataTransferItemList, index: u32, frame: *Frame) !void {
+ return self._data_transfer.removeItem(index, frame);
+}
+
+pub fn clear(self: *DataTransferItemList, frame: *Frame) !void {
+ return self._data_transfer.clearItems(frame);
+}
+
+pub fn iterator(self: *DataTransferItemList, exec: *const js.Execution) !*Iterator {
+ return Iterator.init(.{
+ .index = 0,
+ .list = self,
+ }, exec);
+}
+
+const GenericIterator = @import("collections/iterator.zig").Entry;
+pub const Iterator = GenericIterator(struct {
+ index: u32,
+ list: *DataTransferItemList,
+
+ pub fn next(self: *@This(), _: *const js.Execution) ?*DataTransferItem {
+ const index = self.index;
+ const it = self.list.item(index) orelse return null;
+ self.index = index + 1;
+ return it;
+ }
+}, null);
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DataTransferItemList);
+
+ pub const Meta = struct {
+ pub const name = "DataTransferItemList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(DataTransferItemList.getLength, null, .{});
+ pub const add = bridge.function(DataTransferItemList.add, .{});
+ pub const remove = bridge.function(DataTransferItemList.remove, .{});
+ pub const clear = bridge.function(DataTransferItemList.clear, .{});
+ pub const @"[]" = bridge.indexed(DataTransferItemList.item, getIndexes, .{ .null_as_undefined = true });
+ pub const symbol_iterator = bridge.iterator(DataTransferItemList.iterator, .{});
+
+ fn getIndexes(self: *DataTransferItemList, exec: *const js.Execution) !js.Array {
+ const len = self.getLength();
+ var arr = exec.js.local.?.newArray(len);
+ for (0..len) |i| {
+ _ = try arr.set(@intCast(i), i, .{});
+ }
+ return arr;
+ }
+};