diff --git a/package-lock.json b/package-lock.json
index 27d85cb4b..a03d894f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11167,6 +11167,10 @@
       "resolved": "packages/uui-input-lock",
       "link": true
     },
+    "node_modules/@umbraco-ui/uui-input-otp": {
+      "resolved": "packages/uui-input-otp",
+      "link": true
+    },
     "node_modules/@umbraco-ui/uui-input-password": {
       "resolved": "packages/uui-input-password",
       "link": true
@@ -33079,6 +33083,13 @@
         "@umbraco-ui/uui-input": "1.8.0"
       }
     },
+    "packages/uui-input-otp": {
+      "version": "0.0.0",
+      "license": "MIT",
+      "dependencies": {
+        "@umbraco-ui/uui-base": "1.8.0"
+      }
+    },
     "packages/uui-input-password": {
       "name": "@umbraco-ui/uui-input-password",
       "version": "1.8.0",
diff --git a/packages/uui-input-otp/README.md b/packages/uui-input-otp/README.md
new file mode 100644
index 000000000..678a69cfc
--- /dev/null
+++ b/packages/uui-input-otp/README.md
@@ -0,0 +1,31 @@
+# uui-input-otp
+
+![npm](https://img.shields.io/npm/v/@umbraco-ui/uui-input-otp?logoColor=%231B264F)
+
+Umbraco style input-otp component.
+
+## Installation
+
+### ES imports
+
+```zsh
+npm i @umbraco-ui/uui-input-otp
+```
+
+Import the registration of `<uui-input-otp>` via:
+
+```javascript
+import '@umbraco-ui/uui-input-otp';
+```
+
+When looking to leverage the `UUIInputOtpElement` base class as a type and/or for extension purposes, do so via:
+
+```javascript
+import { UUIInputOtpElement } from '@umbraco-ui/uui-input-otp';
+```
+
+## Usage
+
+```html
+<uui-input-otp></uui-input-otp>
+```
diff --git a/packages/uui-input-otp/lib/index.ts b/packages/uui-input-otp/lib/index.ts
new file mode 100644
index 000000000..dc4dbf594
--- /dev/null
+++ b/packages/uui-input-otp/lib/index.ts
@@ -0,0 +1 @@
+export * from './uui-input-otp.element';
diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts
new file mode 100644
index 000000000..20fdaa5e4
--- /dev/null
+++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts
@@ -0,0 +1,328 @@
+import {
+  LabelMixin,
+  UUIFormControlMixin,
+} from '@umbraco-ui/uui-base/lib/mixins';
+import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
+import { UUIInputEvent, type InputType } from '@umbraco-ui/uui-input/lib';
+
+import { css, html, LitElement } from 'lit';
+import { property, state } from 'lit/decorators.js';
+import { ifDefined } from 'lit/directives/if-defined.js';
+import { repeat } from 'lit/directives/repeat.js';
+
+/**
+ * @element uui-input-otp
+ */
+@defineElement('uui-input-otp')
+export class UUIInputOtpElement extends UUIFormControlMixin(
+  LabelMixin('', LitElement),
+  '',
+) {
+  /**
+   * This is a static class field indicating that the element is can be used inside a native form and participate in its events. It may require a polyfill, check support here https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/attachInternals.  Read more about form controls here https://web.dev/more-capable-form-controls/
+   */
+  static readonly formAssociated = true;
+
+  /**
+   * Accepts only numbers
+   * @default false
+   * @attr
+   */
+  @property({ type: Boolean, attribute: 'integer-only' })
+  set integerOnly(value: boolean) {
+    this.inputMode = value ? 'numeric' : 'text';
+  }
+  get integerOnly() {
+    return this.inputMode === 'numeric';
+  }
+
+  /**
+   * If true, the input will be masked
+   * @default false
+   * @attr
+   */
+  @property({ type: Boolean })
+  set masked(value: boolean) {
+    this._input = value ? 'password' : 'text';
+  }
+  get masked() {
+    return this._input === 'password';
+  }
+
+  /**
+   * The number of characters in the input
+   * @default 6
+   * @attr
+   */
+  @property({ type: Number })
+  length = 6;
+
+  /**
+   * The template for the item label
+   */
+  @property({ type: String, attribute: false })
+  itemLabelTemplate = (index: number) => `Character number ${index + 1}`;
+
+  /**
+   * Set to true to make this input readonly.
+   * @attr
+   * @default false
+   */
+  @property({ type: Boolean, reflect: true })
+  readonly = false;
+
+  /**
+   * Set to true to disable this input.
+   * @attr
+   * @default false
+   */
+  @property({ type: Boolean, reflect: true })
+  disabled = false;
+
+  /**
+   * Set to true to autofocus this input.
+   * @attr
+   * @default false
+   */
+  @property({ type: Boolean, reflect: true, attribute: 'autofocus' })
+  autoFocus = false;
+
+  /**
+   * Add a placeholder to the inputs in the group
+   * @remark The placeholder should be a string with the same length as the `length` attribute and will be distributed to each input in the group
+   * @attr
+   * @default ''
+   */
+  @property()
+  placeholder = '';
+
+  /**
+   * The autocomplete attribute specifies whether or not an input field should have autocomplete enabled.
+   * @remark Set the autocomplete attribute to "one-time-code" to enable autofill of one-time-code inputs
+   * @attr
+   * @default ''
+   * @type {string}
+   */
+  @property({ type: String, reflect: true })
+  autocomplete?: string;
+
+  /**
+   * Min length validation message.
+   * @attr
+   * @default
+   */
+  @property({ type: String, attribute: 'minlength-message' })
+  minlengthMessage = 'This field need more characters';
+
+  @state()
+  _input: InputType = 'text';
+
+  @state()
+  _tokens: string[] = [];
+
+  set value(value: string) {
+    this._tokens = value.split('');
+
+    super.value = value;
+    this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE));
+  }
+  get value() {
+    return super.value.toString();
+  }
+
+  constructor() {
+    super();
+    this.addEventListener('paste', this.onPaste.bind(this));
+
+    this.addValidator(
+      'tooShort',
+      () => this.minlengthMessage,
+      () => !!this.length && String(this.value).length < this.length,
+    );
+  }
+
+  protected getFormElement(): HTMLElement | null | undefined {
+    return this;
+  }
+
+  protected onFocus(event: FocusEvent) {
+    (event.target as HTMLInputElement)?.select();
+    this.dispatchEvent(event);
+  }
+
+  protected onBlur(event: FocusEvent) {
+    this.dispatchEvent(event);
+  }
+
+  protected onInput(event: InputEvent, index: number) {
+    const target = event.target as HTMLInputElement;
+    this._tokens[index] = target?.value;
+    this.value = this._tokens.join('');
+
+    if (event.inputType === 'deleteContentBackward') {
+      this.moveToPrev(event);
+    } else if (
+      event.inputType === 'insertText' ||
+      event.inputType === 'deleteContentForward'
+    ) {
+      this.moveToNext(event);
+    }
+  }
+
+  protected onKeyDown(event: KeyboardEvent) {
+    if (event.ctrlKey || event.metaKey) {
+      return;
+    }
+
+    switch (event.code) {
+      case 'ArrowLeft':
+        this.moveToPrev(event);
+        event.preventDefault();
+
+        break;
+
+      case 'ArrowUp':
+      case 'ArrowDown':
+        event.preventDefault();
+
+        break;
+
+      case 'Backspace':
+        if ((event.target as HTMLInputElement)?.value.length === 0) {
+          this.moveToPrev(event);
+          event.preventDefault();
+        }
+
+        break;
+
+      case 'ArrowRight':
+        this.moveToNext(event);
+        event.preventDefault();
+
+        break;
+
+      default:
+        if (
+          (this.integerOnly &&
+            !(Number(event.key) >= 0 && Number(event.key) <= 9)) ||
+          (this._tokens.join('').length >= this.length &&
+            event.code !== 'Delete')
+        ) {
+          event.preventDefault();
+        }
+
+        break;
+    }
+  }
+
+  protected onPaste(event: ClipboardEvent) {
+    const paste = event.clipboardData?.getData('text');
+
+    if (paste?.length) {
+      const pastedCode = paste.substring(0, this.length + 1);
+
+      if (!this.integerOnly || !isNaN(pastedCode as any)) {
+        this.value = pastedCode;
+      }
+    }
+
+    event.preventDefault();
+  }
+
+  protected moveToPrev(event: Event) {
+    if (!event.target) return;
+    const prevInput = this.findPrevInput(event.target);
+
+    if (prevInput) {
+      prevInput.focus();
+      prevInput.select();
+    }
+  }
+
+  protected moveToNext(event: Event) {
+    if (!event.target) return;
+    const nextInput = this.findNextInput(event.target);
+
+    if (nextInput) {
+      nextInput.focus();
+      nextInput.select();
+    }
+  }
+
+  protected findNextInput(element: EventTarget): HTMLInputElement | null {
+    const nextElement = (element as Element).nextElementSibling;
+
+    if (!nextElement) return null;
+
+    return nextElement.nodeName === 'INPUT'
+      ? (nextElement as HTMLInputElement)
+      : this.findNextInput(nextElement);
+  }
+
+  protected findPrevInput(element: EventTarget): HTMLInputElement | null {
+    const prevElement = (element as Element).previousElementSibling;
+
+    if (!prevElement) return null;
+
+    return prevElement.nodeName === 'INPUT'
+      ? (prevElement as HTMLInputElement)
+      : this.findPrevInput(prevElement);
+  }
+
+  protected renderInput(index: number) {
+    return html`
+      <input
+        class="otp-input"
+        type=${this._input}
+        .value=${this._tokens[index] || ''}
+        .placeholder=${this.placeholder.charAt(index) || ''}
+        .inputMode=${this.inputMode}
+        ?readonly=${this.readonly}
+        ?disabled=${this.disabled}
+        ?autofocus=${this.autoFocus && index === 0}
+        aria-label=${this.itemLabelTemplate(index)}
+        @input=${(e: InputEvent) => this.onInput(e, index)}
+        @keydown=${this.onKeyDown} />
+    `;
+  }
+
+  render() {
+    return html`
+      <fieldset id="otp-input-group" aria-label=${ifDefined(this.label)}>
+        ${repeat(Array.from({ length: this.length }), (_, i) =>
+          this.renderInput(i),
+        )}
+      </fieldset>
+    `;
+  }
+
+  static readonly styles = [
+    css`
+      :host(:not([pristine]):invalid) .otp-input,
+      :host(:not([pristine])) .otp-input:invalid,
+      /* polyfill support */
+      :host(:not([pristine])[internals-invalid]) .otp-input:invalid {
+        border-color: var(--uui-color-danger);
+      }
+
+      #otp-input-group {
+        display: flex;
+        border: 0; /* Reset fieldset */
+      }
+
+      .otp-input {
+        width: 3em;
+        height: 3em;
+        text-align: center;
+        font-size: 1.5em;
+        margin-right: 0.5em;
+      }
+    `,
+  ];
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'uui-input-otp': UUIInputOtpElement;
+  }
+}
diff --git a/packages/uui-input-otp/lib/uui-input-otp.test.ts b/packages/uui-input-otp/lib/uui-input-otp.test.ts
new file mode 100644
index 000000000..2a472706c
--- /dev/null
+++ b/packages/uui-input-otp/lib/uui-input-otp.test.ts
@@ -0,0 +1,143 @@
+import { html, fixture, expect } from '@open-wc/testing';
+import { UUIInputOtpElement } from './uui-input-otp.element';
+
+describe('UUIInputOtpElement', () => {
+  let element: UUIInputOtpElement;
+
+  beforeEach(async () => {
+    element = await fixture(html`
+      <uui-input-otp label="otp input"></uui-input-otp>
+    `);
+  });
+
+  it('is defined with its own instance', () => {
+    expect(element).to.be.instanceOf(UUIInputOtpElement);
+  });
+
+  it('passes the a11y audit', async () => {
+    await expect(element).shadowDom.to.be.accessible();
+  });
+
+  describe('properties', () => {
+    it('has a default length of 6', () => {
+      expect(element.length).to.equal(6);
+      expect(element.shadowRoot?.querySelectorAll('input').length).to.equal(6);
+    });
+
+    it('can set the length', async () => {
+      element.length = 4;
+      await element.updateComplete;
+      expect(element.shadowRoot?.querySelectorAll('input').length).to.equal(4);
+    });
+
+    it('can set integerOnly', async () => {
+      element.integerOnly = true;
+      await element.updateComplete;
+      expect(element.inputMode).to.equal('numeric');
+
+      element.integerOnly = false;
+      await element.updateComplete;
+      expect(element.inputMode).to.equal('text');
+    });
+
+    it('can set masked', async () => {
+      element.masked = true;
+      await element.updateComplete;
+      expect(element._input).to.equal('password');
+
+      element.masked = false;
+      await element.updateComplete;
+      expect(element._input).to.equal('text');
+    });
+
+    it('can set readonly', async () => {
+      element.readonly = true;
+      await element.updateComplete;
+      expect(element.hasAttribute('readonly')).to.be.true;
+
+      element.readonly = false;
+      await element.updateComplete;
+      expect(element.hasAttribute('readonly')).to.be.false;
+    });
+
+    it('can set disabled', async () => {
+      element.disabled = true;
+      await element.updateComplete;
+      expect(element.hasAttribute('disabled')).to.be.true;
+
+      element.disabled = false;
+      await element.updateComplete;
+      expect(element.hasAttribute('disabled')).to.be.false;
+    });
+
+    it('can set autofocus', async () => {
+      element.autofocus = true;
+      await element.updateComplete;
+      expect(element.hasAttribute('autofocus')).to.be.true;
+
+      element.autofocus = false;
+      await element.updateComplete;
+      expect(element.hasAttribute('autofocus')).to.be.false;
+    });
+
+    it('can set required', async () => {
+      element.required = true;
+      await element.updateComplete;
+      expect(element.hasAttribute('required')).to.be.true;
+
+      element.required = false;
+      await element.updateComplete;
+      expect(element.hasAttribute('required')).to.be.false;
+    });
+
+    it('can set error', async () => {
+      element.error = true;
+      await element.updateComplete;
+      expect(element.hasAttribute('error')).to.be.true;
+
+      element.error = false;
+      await element.updateComplete;
+      expect(element.hasAttribute('error')).to.be.false;
+    });
+
+    it('can set autocomplete', async () => {
+      element.autocomplete = 'one-time-code';
+      await element.updateComplete;
+      expect(element.getAttribute('autocomplete')).to.equal('one-time-code');
+    });
+  });
+
+  describe('logic', () => {
+    it('can distribute a value', async () => {
+      element.value = '123456';
+      await element.updateComplete;
+      const inputs = element.shadowRoot?.querySelectorAll('input');
+      if (!inputs) {
+        throw new Error('inputs not found');
+      }
+
+      expect(inputs[0].value).to.equal('1');
+      expect(inputs[1].value).to.equal('2');
+      expect(inputs[2].value).to.equal('3');
+      expect(inputs[3].value).to.equal('4');
+      expect(inputs[4].value).to.equal('5');
+      expect(inputs[5].value).to.equal('6');
+    });
+
+    it('can distribute a value with a different length', async () => {
+      element.length = 4;
+      element.value = '123456';
+      await element.updateComplete;
+
+      const inputs = element.shadowRoot?.querySelectorAll('input');
+      if (!inputs) {
+        throw new Error('inputs not found');
+      }
+
+      expect(inputs[0].value).to.equal('1');
+      expect(inputs[1].value).to.equal('2');
+      expect(inputs[2].value).to.equal('3');
+      expect(inputs[3].value).to.equal('4');
+    });
+  });
+});
diff --git a/packages/uui-input-otp/package.json b/packages/uui-input-otp/package.json
new file mode 100644
index 000000000..e587c922f
--- /dev/null
+++ b/packages/uui-input-otp/package.json
@@ -0,0 +1,44 @@
+{
+  "name": "@umbraco-ui/uui-input-otp",
+  "version": "0.0.0",
+  "license": "MIT",
+  "keywords": [
+    "Umbraco",
+    "Custom elements",
+    "Web components",
+    "UI",
+    "Lit",
+    "Input Otp"
+  ],
+  "description": "Umbraco UI input-otp component",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/umbraco/Umbraco.UI.git",
+    "directory": "packages/uui-input-otp"
+  },
+  "bugs": {
+    "url": "https://github.com/umbraco/Umbraco.UI/issues"
+  },
+  "main": "./lib/index.js",
+  "module": "./lib/index.js",
+  "types": "./lib/index.d.ts",
+  "type": "module",
+  "customElements": "custom-elements.json",
+  "files": [
+    "lib/**/*.d.ts",
+    "lib/**/*.js",
+    "custom-elements.json"
+  ],
+  "dependencies": {
+    "@umbraco-ui/uui-base": "1.8.0"
+  },
+  "scripts": {
+    "build": "npm run analyze && tsc --build --force && rollup -c rollup.config.js",
+    "clean": "tsc --build --clean && rimraf -g dist lib/*.js lib/**/*.js *.tgz lib/**/*.d.ts custom-elements.json",
+    "analyze": "web-component-analyzer **/*.element.ts --outFile custom-elements.json"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "homepage": "https://uui.umbraco.com/?path=/story/uui-input-otp"
+}
diff --git a/packages/uui-input-otp/rollup.config.js b/packages/uui-input-otp/rollup.config.js
new file mode 100644
index 000000000..34524a90d
--- /dev/null
+++ b/packages/uui-input-otp/rollup.config.js
@@ -0,0 +1,5 @@
+import { UUIProdConfig } from '../rollup-package.config.mjs';
+
+export default UUIProdConfig({
+  entryPoints: ['index'],
+});
diff --git a/packages/uui-input-otp/stories/uui-input-otp.automatic-otp.mdx b/packages/uui-input-otp/stories/uui-input-otp.automatic-otp.mdx
new file mode 100644
index 000000000..733a31945
--- /dev/null
+++ b/packages/uui-input-otp/stories/uui-input-otp.automatic-otp.mdx
@@ -0,0 +1,48 @@
+import { Meta, Canvas, Story } from '@storybook/addon-docs/blocks';
+
+import * as OtpStories from './uui-input-otp.story';
+
+<Meta title="Inputs/Input OTP/Automatic Code Input" />
+
+# OTP With Autocomplete
+
+The OTP input field can be used with an autocomplete feature. This feature is useful to utilise the browser's autocomplete feature to autofill the OTP code.
+
+## Usage
+
+First you need to configure the input field to autocomplete the OTP code. This can be done by setting the `autocomplete` attribute to `one-time-code`.
+
+<Canvas of={OtpStories.AutocompleteOtp} />
+
+This allows browsers that support the `one-time-code` autocomplete to autofill the OTP code. It is mostly used in Safari.
+
+Safari does not yet support the Web OTP API, so this is a workaround to autofill the OTP code.
+
+Other browsers that support the Web OTP API will not autofill the OTP code, as the Web OTP API is more secure than the `one-time-code` autocomplete.
+
+Therefore, we need to listen for Web OTP API events to autofill the OTP code in browsers that support it.
+We can add the following code to listen for the Web OTP API events:
+
+```js
+// Feature detect the Web OTP API
+if ('OTPCredential' in window) {
+  // Request the OTP credential
+  navigator.credentials
+    .get({
+      otp: { transport: ['sms'] }, // This can be 'sms' or other transports
+    })
+    .then(cred => {
+      // Locate the OTP input from before
+      const otpInput = document.getElementById('otp-input');
+
+      // Autofill the OTP input if the credential is available and the OTP input is found
+      if (cred && otpInput) {
+        this.otpInput.value = cred.id;
+      }
+    })
+    .catch(() => {
+      // optionally catch errors, which could mean the OTP retrieval has timed out
+      // in most cases, it is not necessary to handle this error
+    });
+}
+```
diff --git a/packages/uui-input-otp/stories/uui-input-otp.story.ts b/packages/uui-input-otp/stories/uui-input-otp.story.ts
new file mode 100644
index 000000000..a8cf61bd7
--- /dev/null
+++ b/packages/uui-input-otp/stories/uui-input-otp.story.ts
@@ -0,0 +1,90 @@
+import type { Meta, StoryObj } from '@storybook/web-components';
+
+import '../lib/uui-input-otp.element';
+import type { UUIInputOtpElement } from '../lib/uui-input-otp.element';
+import readme from '../README.md?raw';
+
+const meta: Meta<UUIInputOtpElement> = {
+  id: 'uui-input-otp',
+  title: 'Inputs/Input Otp',
+  component: 'uui-input-otp',
+  parameters: {
+    readme: { markdown: readme },
+    docs: {
+      source: {
+        code: `<uui-input-otp></uui-input-otp>`,
+      },
+    },
+  },
+};
+
+export default meta;
+type Story = StoryObj<UUIInputOtpElement>;
+
+export const Overview: Story = {};
+
+export const IntegerOnly: Story = {
+  args: {
+    integerOnly: true,
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `<uui-input-otp integer-only></uui-input-otp>`,
+      },
+    },
+  },
+};
+
+export const Masked: Story = {
+  args: {
+    masked: true,
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `<uui-input-otp masked></uui-input-otp>`,
+      },
+    },
+  },
+};
+
+export const Required: Story = {
+  args: {
+    required: true,
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `<uui-input-otp required></uui-input-otp>`,
+      },
+    },
+  },
+};
+
+export const WithError: Story = {
+  args: {
+    error: true,
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `<uui-input-otp error></uui-input-otp>`,
+      },
+    },
+  },
+};
+
+export const AutocompleteOtp: Story = {
+  name: 'Autocomplete OTP',
+  args: {
+    autocomplete: 'one-time-code',
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `<uui-input-otp autocomplete="one-time-code"></uui-input-otp>`,
+      },
+    },
+  },
+};
diff --git a/packages/uui-input-otp/tsconfig.json b/packages/uui-input-otp/tsconfig.json
new file mode 100644
index 000000000..40d176776
--- /dev/null
+++ b/packages/uui-input-otp/tsconfig.json
@@ -0,0 +1,17 @@
+// Don't edit this file directly. It is generated by /scripts/generate-ts-config.js
+
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./lib",
+    "rootDir": "./lib",
+    "composite": true
+  },
+  "include": ["./**/*.ts"],
+  "exclude": ["./**/*.test.ts", "./**/*.story.ts"],
+  "references": [
+    {
+      "path": "../uui-base"
+    }
+  ]
+}
diff --git a/packages/uui/lib/index.ts b/packages/uui/lib/index.ts
index 7a6290236..6a2d7fe33 100644
--- a/packages/uui/lib/index.ts
+++ b/packages/uui/lib/index.ts
@@ -36,6 +36,7 @@ export * from '@umbraco-ui/uui-icon-registry/lib';
 export * from '@umbraco-ui/uui-icon/lib';
 export * from '@umbraco-ui/uui-input-file/lib';
 export * from '@umbraco-ui/uui-input-lock/lib';
+export * from '@umbraco-ui/uui-input-otp/lib/index.js';
 export * from '@umbraco-ui/uui-input-password/lib';
 export * from '@umbraco-ui/uui-input/lib';
 export * from '@umbraco-ui/uui-keyboard-shortcut/lib';