diff --git a/docs/src/api/class-elementhandle.md b/docs/src/api/class-elementhandle.md
index dbf99eb3a250b3..faeedb4a13305a 100644
--- a/docs/src/api/class-elementhandle.md
+++ b/docs/src/api/class-elementhandle.md
@@ -953,6 +953,7 @@ When all steps combined have not finished during the specified [`option: timeout
 
 Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they
 are resolved relative to the current working directory. For empty array, clears the selected files.
+In order to upload a directory (`[webkitdirectory]`) pass the path to the directory.
 
 This method expects [ElementHandle] to point to an
 [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside the `<label>` element that has an associated [control](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement/control), targets the control instead.
diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md
index af7f8754c753b0..80c57a9a3d362f 100644
--- a/docs/src/api/class-locator.md
+++ b/docs/src/api/class-locator.md
@@ -2164,6 +2164,7 @@ When all steps combined have not finished during the specified [`option: timeout
 * since: v1.14
 
 Upload file or multiple files into `<input type=file>`.
+In order to upload a directory (`[webkitdirectory]`) pass the path to the directory.
 
 **Usage**
 
diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md
index b4ee91eb1b3019..0b722dd07a913e 100644
--- a/docs/src/api/class-page.md
+++ b/docs/src/api/class-page.md
@@ -3927,6 +3927,7 @@ An object containing additional HTTP headers to be sent with every request. All
 
 Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they
 are resolved relative to the current working directory. For empty array, clears the selected files.
+In order to upload a directory (`[webkitdirectory]`) pass the path to the directory.
 
 This method expects [`param: selector`] to point to an
 [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside the `<label>` element that has an associated [control](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement/control), targets the control instead.
diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts
index 9315d50eaeeaa9..e7fbcca0fce904 100644
--- a/packages/playwright-core/src/client/elementHandle.ts
+++ b/packages/playwright-core/src/client/elementHandle.ts
@@ -262,16 +262,42 @@ export async function convertInputFiles(files: string | FilePayload | string[] |
   if (items.some(item => typeof item === 'string')) {
     if (!items.every(item => typeof item === 'string'))
       throw new Error('File paths cannot be mixed with buffers');
+    const directoryCount = (await Promise.all(items.map(async item => (await fs.promises.stat(item)).isDirectory()))).filter(Boolean).length;
+    if (directoryCount > 1)
+      throw new Error('Only one directory can be uploaded at a time');
 
     if (context._connection.isRemote()) {
-      const streams: channels.WritableStreamChannel[] = await Promise.all((items as string[]).map(async item => {
-        const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs;
-        const { writableStream: stream } = await context._wrapApiCall(() => context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }), true);
-        const writable = WritableStream.from(stream);
-        await pipelineAsync(fs.createReadStream(item), writable.stream());
-        return stream;
-      }));
-      return { streams };
+      let streams: channels.WritableStreamChannel[] | undefined;
+      let localPaths: string[] | undefined;
+      for (const item of items as string[]) {
+        if ((await fs.promises.stat(item)).isDirectory()) {
+          const files = (await fs.promises.readdir(item, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => path.join(item, f.name));
+          const { writableStreams, dir } = await context._wrapApiCall(async () => context._channel.createTempDirectory({
+            root: item,
+            items: await Promise.all(files.map(async f => {
+              const lastModifiedMs = (await fs.promises.stat(f)).mtimeMs;
+              return {
+                name: path.relative(item, f),
+                lastModifiedMs
+              };
+            })),
+          }), true);
+          for (let i = 0; i < files.length; i++) {
+            const writable = WritableStream.from(writableStreams[i]);
+            await pipelineAsync(fs.createReadStream(files[i]), writable.stream());
+          }
+          localPaths ??= [];
+          localPaths.push(dir);
+        } else {
+          const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs;
+          const { writableStream } = await context._wrapApiCall(() => context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }), true);
+          const writable = WritableStream.from(writableStream);
+          await pipelineAsync(fs.createReadStream(item), writable.stream());
+          streams ??= [];
+          streams.push(writableStream);
+        }
+      }
+      return { streams, localPaths };
     }
     return { localPaths: items.map(f => path.resolve(f as string)) as string[] };
   }
diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts
index 0a60a92ebb480f..35582ecce9e761 100644
--- a/packages/playwright-core/src/protocol/validator.ts
+++ b/packages/playwright-core/src/protocol/validator.ts
@@ -958,6 +958,17 @@ scheme.BrowserContextCreateTempFileParams = tObject({
 scheme.BrowserContextCreateTempFileResult = tObject({
   writableStream: tChannel(['WritableStream']),
 });
+scheme.BrowserContextCreateTempDirectoryParams = tObject({
+  root: tString,
+  items: tArray(tObject({
+    name: tString,
+    lastModifiedMs: tOptional(tNumber),
+  })),
+});
+scheme.BrowserContextCreateTempDirectoryResult = tObject({
+  dir: tString,
+  writableStreams: tArray(tChannel(['WritableStream'])),
+});
 scheme.BrowserContextUpdateSubscriptionParams = tObject({
   event: tEnum(['console', 'dialog', 'request', 'response', 'requestFinished', 'requestFailed']),
   enabled: tBoolean,
diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts
index dd1f61f57b531f..360b52456288b5 100644
--- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts
+++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts
@@ -187,6 +187,21 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
     return { writableStream: new WritableStreamDispatcher(this, file, params.lastModifiedMs) };
   }
 
+  async createTempDirectory(params: channels.BrowserContextCreateTempDirectoryParams): Promise<channels.BrowserContextCreateTempDirectoryResult> {
+    const dir = this._context._browser.options.artifactsDir;
+    const tmpDir = path.join(dir, 'upload-' + createGuid());
+    const tempDirWithRootName = path.join(tmpDir, path.basename(params.root));
+    await fs.promises.mkdir(tempDirWithRootName, { recursive: true });
+    return {
+      dir: tempDirWithRootName,
+      writableStreams: await Promise.all(params.items.map(async item => {
+        await fs.promises.mkdir(path.dirname(path.join(tempDirWithRootName, item.name)), { recursive: true });
+        const file = fs.createWriteStream(path.join(tempDirWithRootName, item.name));
+        return new WritableStreamDispatcher(this, file, item.lastModifiedMs);
+      }))
+    };
+  }
+
   async setDefaultNavigationTimeoutNoReply(params: channels.BrowserContextSetDefaultNavigationTimeoutNoReplyParams) {
     this._context.setDefaultNavigationTimeout(params.timeout);
   }
diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts
index 01e70f16375ebe..a9942df66a39c0 100644
--- a/packages/playwright-core/src/server/dom.ts
+++ b/packages/playwright-core/src/server/dom.ts
@@ -633,7 +633,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
         return;
       if (element.tagName !== 'INPUT')
         throw injected.createStacklessError('Node is not an HTMLInputElement');
-      if (multiple && !(element as HTMLInputElement).multiple)
+      if (multiple && !(element as HTMLInputElement).multiple && !(element as HTMLInputElement).webkitdirectory)
         throw injected.createStacklessError('Non-multiple file input can only accept single file');
       return element;
     }, multiple);
@@ -647,7 +647,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
         await Promise.all(localPaths.map(localPath => (
           fs.promises.access(localPath, fs.constants.F_OK)
         )));
+        const isDirectoryUpload = localPaths.length === 1 ? (await fs.promises.stat(localPaths[0])).isDirectory() : false;
+        const waitForChangeEvent = isDirectoryUpload ? this.evaluateInUtility(([_, node]) => new Promise<any>(fulfil => {
+          node.addEventListener('change', fulfil, { once: true });
+        }), undefined) : Promise.resolve();
         await this._page._delegate.setInputFilePaths(retargeted, localPaths);
+        await waitForChangeEvent;
       } else {
         await this._page._delegate.setInputFiles(retargeted, filePayloads!);
       }
diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts
index dc91c655f09adc..827bcc2649dace 100644
--- a/packages/playwright-core/types/types.d.ts
+++ b/packages/playwright-core/types/types.d.ts
@@ -4055,7 +4055,8 @@ export interface Page {
    * instead. Read more about [locators](https://playwright.dev/docs/locators).
    *
    * Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then
-   * they are resolved relative to the current working directory. For empty array, clears the selected files.
+   * they are resolved relative to the current working directory. For empty array, clears the selected files. In order
+   * to upload a directory (`[webkitdirectory]`) pass the path to the directory.
    *
    * This method expects `selector` to point to an
    * [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside
@@ -10579,7 +10580,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
    * instead. Read more about [locators](https://playwright.dev/docs/locators).
    *
    * Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then
-   * they are resolved relative to the current working directory. For empty array, clears the selected files.
+   * they are resolved relative to the current working directory. For empty array, clears the selected files. In order
+   * to upload a directory (`[webkitdirectory]`) pass the path to the directory.
    *
    * This method expects {@link ElementHandle} to point to an
    * [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside
@@ -12786,7 +12788,8 @@ export interface Locator {
   }): Promise<void>;
 
   /**
-   * Upload file or multiple files into `<input type=file>`.
+   * Upload file or multiple files into `<input type=file>`. In order to upload a directory (`[webkitdirectory]`) pass
+   * the path to the directory.
    *
    * **Usage**
    *
diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts
index c8523e8de5c2cf..60f6d055bdda35 100644
--- a/packages/protocol/src/channels.ts
+++ b/packages/protocol/src/channels.ts
@@ -1459,6 +1459,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
   harStart(params: BrowserContextHarStartParams, metadata?: CallMetadata): Promise<BrowserContextHarStartResult>;
   harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise<BrowserContextHarExportResult>;
   createTempFile(params: BrowserContextCreateTempFileParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempFileResult>;
+  createTempDirectory(params: BrowserContextCreateTempDirectoryParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempDirectoryResult>;
   updateSubscription(params: BrowserContextUpdateSubscriptionParams, metadata?: CallMetadata): Promise<BrowserContextUpdateSubscriptionResult>;
   clockInstallFakeTimers(params: BrowserContextClockInstallFakeTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockInstallFakeTimersResult>;
   clockRunAllTimers(params?: BrowserContextClockRunAllTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockRunAllTimersResult>;
@@ -1747,6 +1748,20 @@ export type BrowserContextCreateTempFileOptions = {
 export type BrowserContextCreateTempFileResult = {
   writableStream: WritableStreamChannel,
 };
+export type BrowserContextCreateTempDirectoryParams = {
+  root: string,
+  items: {
+    name: string,
+    lastModifiedMs?: number,
+  }[],
+};
+export type BrowserContextCreateTempDirectoryOptions = {
+
+};
+export type BrowserContextCreateTempDirectoryResult = {
+  dir: string,
+  writableStreams: WritableStreamChannel[],
+};
 export type BrowserContextUpdateSubscriptionParams = {
   event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed',
   enabled: boolean,
diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml
index 7f6bd3bfe1adec..8d2b9216f81981 100644
--- a/packages/protocol/src/protocol.yml
+++ b/packages/protocol/src/protocol.yml
@@ -1191,6 +1191,22 @@ BrowserContext:
       returns:
         writableStream: WritableStream
 
+    createTempDirectory:
+      parameters:
+        root: string
+        items:
+          type: array
+          items:
+            type: object
+            properties:
+              name: string
+              lastModifiedMs: number?
+      returns:
+        dir: string
+        writableStreams:
+          type: array
+          items: WritableStream
+
     updateSubscription:
       parameters:
         event:
diff --git a/tests/assets/input/folderupload.html b/tests/assets/input/folderupload.html
new file mode 100644
index 00000000000000..16c7e2c3e91bce
--- /dev/null
+++ b/tests/assets/input/folderupload.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Folder upload test</title>
+  </head>
+  <body>
+    <form action="/upload" method="post" enctype="multipart/form-data">
+      <input type="file" name="file1" webkitdirectory>
+      <input type="submit">
+    </form>
+  </body>
+</html>
\ No newline at end of file
diff --git a/tests/page/page-set-input-files.spec.ts b/tests/page/page-set-input-files.spec.ts
index 5777b1087a624b..21f1c253116b96 100644
--- a/tests/page/page-set-input-files.spec.ts
+++ b/tests/page/page-set-input-files.spec.ts
@@ -37,6 +37,39 @@ it('should upload the file', async ({ page, server, asset }) => {
   }, input)).toBe('contents of the file');
 });
 
+async function createTestDirectoryStructure() {
+  const baseDir = path.join(it.info().outputDir, 'file-upload-test');
+  await fs.promises.mkdir(baseDir, { recursive: true });
+  await fs.promises.writeFile(path.join(baseDir, 'file1.txt'), 'file1 content');
+  await fs.promises.writeFile(path.join(baseDir, 'file2'), 'file2 content');
+  await fs.promises.mkdir(path.join(baseDir, 'sub-dir'));
+  await fs.promises.writeFile(path.join(baseDir, 'sub-dir', 'really.txt'), 'sub-dir file content');
+  return baseDir;
+}
+
+it('should upload a folder', async ({ page, server, browserName, headless, browserMajorVersion }) => {
+  await page.goto(server.PREFIX + '/input/folderupload.html');
+  const input = await page.$('input');
+  const dir = await createTestDirectoryStructure();
+  await input.setInputFiles(dir);
+  expect(new Set(await page.evaluate(e => [...e.files].map(f => f.webkitRelativePath), input))).toEqual(new Set([
+    // https://issues.chromium.org/issues/345393164
+    ...((browserName === 'chromium' && headless && browserMajorVersion < 128) ? [] : ['file-upload-test/sub-dir/really.txt']),
+    'file-upload-test/file1.txt',
+    'file-upload-test/file2',
+  ]));
+  const webkitRelativePaths = await page.evaluate(e => [...e.files].map(f => f.webkitRelativePath), input);
+  for (let i = 0; i < webkitRelativePaths.length; i++) {
+    const content = await input.evaluate((e, i) => {
+      const reader = new FileReader();
+      const promise = new Promise(fulfill => reader.onload = fulfill);
+      reader.readAsText(e.files[i]);
+      return promise.then(() => reader.result);
+    }, i);
+    expect(content).toEqual(fs.readFileSync(path.join(dir, '..', webkitRelativePaths[i])).toString());
+  }
+});
+
 it('should upload a file after popup', async ({ page, server, asset }) => {
   it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29923' });
   await page.goto(server.PREFIX + '/input/fileupload.html');