{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "type": "registry:block",
  "title": "OTP Input",
  "description": "A one-time-code field built on a single real input, so SMS autofill, paste and screen readers just work. A glowing caret springs between cells, digits pop in, a sweep runs while the code is checked, wrong codes shake, and on success the cells slide together into one sealed capsule.",
  "docs": "OTP Input was added to public/vividsites/otp-input.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/otp-input",
  "categories": [
    "ui",
    "buttons-inputs"
  ],
  "files": [
    {
      "path": "components/vividsites/otp-input.html",
      "type": "registry:file",
      "target": "public/vividsites/otp-input.html",
      "content": "<!--\nvs-otp-input — a one-time-code field. Underneath is ONE real input\n  (autocomplete=\"one-time-code\", inputmode numeric), so SMS autofill, paste, undo and\n  screen readers all behave; on top, cells with a glowing caret that springs from cell to\n  cell, digits that pop in, a sweep while the code is checked, a shake on a wrong code,\n  and on success the cells slide together into one sealed capsule.\n\n  Markup:  <div class=\"vs-otp-input\" data-length=\"6\" data-theme=\"light\" data-label=\"Verification code\"></div>\n           (data-theme=\"light\" is the look for light pages; remove it on dark surfaces, where\n            the glowing dark look is the default)\n  Attributes:\n    data-length  number of digits (default 6; a gap is drawn in the middle)\n    data-name    name for form submission\n    data-code    optional: the right answer, checked for you (handy for demos)\n    data-theme   \"light\" for light surfaces (default is dark)\n    data-disabled  dims the field and blocks input\n  Variables: --vs-otp-input-glow (caret, focus, success), -cell-w, -cell-h, -gap, -radius,\n    -font, -font-size, -ink, -cell, -line, -error. Set them on .vs-otp-input.\n  Events and methods:\n    \"vs-otp-complete\" fires with detail.code when every cell is filled; answer with\n    el.vsSuccess(), el.vsError() or el.vsReset(). el.vsChecking() shows the sweep.\n  Reduced motion: no pop, sweep or shake; states still change colour.\n-->\n<link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-otp-input\" data-length=\"6\" data-theme=\"light\" data-label=\"Verification code\" data-code=\"204816\"></div>\n\n<style>\n.vs-otp-input {\n  --vs-otp-input-cell-w: 3.3rem;\n  --vs-otp-input-cell-h: 4.1rem;\n  --vs-otp-input-gap: .55rem;\n  --vs-otp-input-radius: 14px;\n  --vs-otp-input-font: 'IBM Plex Mono', ui-monospace, Menlo, monospace;\n  --vs-otp-input-font-size: 1.9rem;\n  --vs-otp-input-ink: #e9fff6;                   /* digits */\n  --vs-otp-input-cell: rgba(233,255,246,.04);    /* cell fill */\n  --vs-otp-input-line: rgba(233,255,246,.14);    /* cell hairline */\n  --vs-otp-input-glow: #3dffa8;                  /* caret, focus glow, success */\n  --vs-otp-input-error: #ff6b6b;\n  --vs-otp-input-on-glow: #04110b;               /* check mark on the glow colour */\n\n  position: relative; display: inline-flex; align-items: center; gap: var(--vs-otp-input-gap);\n  font-family: var(--vs-otp-input-font); color: var(--vs-otp-input-ink); -webkit-tap-highlight-color: transparent;\n}\n/* light surfaces: data-theme=\"light\" swaps the ink, fill and hairline */\n.vs-otp-input[data-theme=\"light\"] {\n  --vs-otp-input-ink: #0f1c17; --vs-otp-input-cell: #f1f4f2; --vs-otp-input-line: rgba(15,28,23,.2);\n  --vs-otp-input-error: #e5484d;\n}\n/* empty cells read as recessed wells, filled ones lift into white tiles, so it holds up on a plain white page */\n.vs-otp-input[data-theme=\"light\"] .vs-otp-input-frame { box-shadow: inset 0 0 0 1px var(--vs-otp-input-line), inset 0 1.5px 2px rgba(15,28,23,.07), 0 1px 0 rgba(255,255,255,.9); }\n.vs-otp-input[data-theme=\"light\"] .vs-otp-input-sep { background: color-mix(in srgb, var(--vs-otp-input-ink) 30%, transparent); }\n.vs-otp-input[data-theme=\"light\"] .vs-otp-input-cell.is-filled .vs-otp-input-frame { background: #fff; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-otp-input-line), var(--vs-otp-input-ink) 14%), 0 1px 2px rgba(15,28,23,.08), 0 8px 18px -10px rgba(15,28,23,.3); }\n.vs-otp-input[data-theme=\"light\"] .vs-otp-input-caret { box-shadow: none; background: color-mix(in srgb, var(--vs-otp-input-glow), #0f1c17 30%); }\n.vs-otp-input[data-theme=\"light\"] .vs-otp-input-ring { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--vs-otp-input-glow), #0f1c17 22%), 0 0 0 4px color-mix(in srgb, var(--vs-otp-input-glow) 30%, transparent), 0 8px 22px -8px color-mix(in srgb, var(--vs-otp-input-glow) 80%, transparent); }\n.vs-otp-input[data-theme=\"light\"] .vs-otp-input-seal { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--vs-otp-input-glow), #0f1c17 22%), 0 0 0 5px color-mix(in srgb, var(--vs-otp-input-glow) 26%, transparent), 0 12px 30px -10px color-mix(in srgb, var(--vs-otp-input-glow) 90%, transparent); background: color-mix(in srgb, var(--vs-otp-input-glow) 14%, #fff); }\n.vs-otp-input[data-theme=\"light\"].is-error .vs-otp-input-digit { color: var(--vs-otp-input-error); }\n.vs-otp-input[data-disabled] { opacity: .42; filter: saturate(.4); }\n.vs-otp-input[data-disabled] .vs-otp-input-field { cursor: not-allowed; }\n.vs-otp-input-field {\n  position: absolute; inset: -6px; z-index: 5; width: calc(100% + 12px); height: calc(100% + 12px);\n  opacity: 0.001; border: 0; padding: 0; margin: 0; background: transparent; color: transparent; caret-color: transparent;\n  font-size: 16px; letter-spacing: 0; outline: none; cursor: text;\n}\n.vs-otp-input-field::selection { background: transparent; }\n.vs-otp-input-cell {\n  position: relative; width: var(--vs-otp-input-cell-w); height: var(--vs-otp-input-cell-h); flex: none;\n  display: grid; place-items: center; border-radius: var(--vs-otp-input-radius); will-change: transform;\n}\n.vs-otp-input-frame {\n  position: absolute; inset: 0; border-radius: inherit; background: var(--vs-otp-input-cell);\n  box-shadow: inset 0 0 0 1px var(--vs-otp-input-line), inset 0 1px 0 rgba(255,255,255,.05);\n  transition: background .25s cubic-bezier(.2,0,0,1), box-shadow .25s cubic-bezier(.2,0,0,1), opacity .35s cubic-bezier(.2,0,0,1);\n}\n.vs-otp-input-cell.is-filled .vs-otp-input-frame { background: color-mix(in srgb, var(--vs-otp-input-cell), var(--vs-otp-input-ink) 3%); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-otp-input-line), var(--vs-otp-input-ink) 12%), inset 0 1px 0 rgba(255,255,255,.06); }\n.vs-otp-input-digit { position: relative; font-size: var(--vs-otp-input-font-size); line-height: 1; font-variant-numeric: tabular-nums; will-change: transform, opacity, filter; }\n.vs-otp-input-sep { width: .8rem; height: 2px; border-radius: 2px; background: var(--vs-otp-input-line); flex: none; transition: opacity .3s; }\n.vs-otp-input-ring {\n  position: absolute; left: 0; top: 0; z-index: 2; width: var(--vs-otp-input-cell-w); height: var(--vs-otp-input-cell-h);\n  border-radius: var(--vs-otp-input-radius); pointer-events: none; opacity: 0; will-change: transform, opacity;\n  box-shadow: inset 0 0 0 1.5px var(--vs-otp-input-glow), 0 0 0 4px color-mix(in srgb, var(--vs-otp-input-glow) 16%, transparent), 0 0 28px -4px color-mix(in srgb, var(--vs-otp-input-glow) 55%, transparent);\n}\n.vs-otp-input-caret {\n  position: absolute; left: 50%; top: 50%; width: 2px; height: 42%; margin: -21% 0 0 -1px; border-radius: 2px;\n  background: var(--vs-otp-input-glow); box-shadow: 0 0 8px var(--vs-otp-input-glow);\n  animation: vs-otp-input-blink 1.06s steps(1) infinite;\n}\n.vs-otp-input-ring.is-full .vs-otp-input-caret { opacity: 0; animation: none; }\n@keyframes vs-otp-input-blink { 50% { opacity: 0; } }\n.vs-otp-input-sweep {\n  position: absolute; inset: -2px; z-index: 3; border-radius: calc(var(--vs-otp-input-radius) + 2px); pointer-events: none; opacity: 0; overflow: hidden;\n}\n.vs-otp-input-sweep::before {\n  content: \"\"; position: absolute; top: 0; bottom: 0; left: 0; width: 40%;\n  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--vs-otp-input-glow) 22%, transparent), transparent);\n  animation: vs-otp-input-sweep 1s cubic-bezier(.45,0,.55,1) infinite;\n}\n@keyframes vs-otp-input-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }\n.vs-otp-input.is-checking .vs-otp-input-sweep { opacity: 1; }\n.vs-otp-input.is-checking .vs-otp-input-digit { opacity: .55; }\n.vs-otp-input.is-error .vs-otp-input-frame { box-shadow: inset 0 0 0 1px var(--vs-otp-input-error), 0 0 22px -6px var(--vs-otp-input-error); background: color-mix(in srgb, var(--vs-otp-input-error) 8%, transparent); }\n.vs-otp-input.is-error .vs-otp-input-digit { color: color-mix(in srgb, var(--vs-otp-input-error), #fff 35%); }\n.vs-otp-input.is-error .vs-otp-input-ring { opacity: 0 !important; }\n.vs-otp-input-seal {\n  position: absolute; z-index: 1; top: 0; bottom: 0; left: 50%; border-radius: var(--vs-otp-input-radius); pointer-events: none; opacity: 0;\n  box-shadow: inset 0 0 0 1.5px var(--vs-otp-input-glow), 0 0 0 5px color-mix(in srgb, var(--vs-otp-input-glow) 12%, transparent), 0 0 40px -6px color-mix(in srgb, var(--vs-otp-input-glow) 60%, transparent);\n  background: color-mix(in srgb, var(--vs-otp-input-glow) 7%, transparent);\n}\n.vs-otp-input-check {\n  position: absolute; z-index: 4; top: 50%; right: 0; width: 2.1rem; height: 2.1rem; margin-top: -1.05rem; border-radius: 50%;\n  display: grid; place-items: center; background: var(--vs-otp-input-glow); color: var(--vs-otp-input-on-glow); opacity: 0; transform: scale(.4);\n}\n.vs-otp-input-check svg { width: 1.1rem; height: 1.1rem; }\n.vs-otp-input.is-success .vs-otp-input-frame, .vs-otp-input.is-success .vs-otp-input-sep { opacity: 0; }\n.vs-otp-input.is-success .vs-otp-input-ring { opacity: 0 !important; }\n.vs-otp-input.is-success .vs-otp-input-field { pointer-events: none; }\n.vs-otp-input-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (hover: hover) { .vs-otp-input:not(.is-success):not(.is-error):hover .vs-otp-input-cell:not(.is-filled) .vs-otp-input-frame { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-otp-input-line), var(--vs-otp-input-ink) 18%); } }\n@media (max-width: 420px) { .vs-otp-input { --vs-otp-input-cell-w: 2.75rem; --vs-otp-input-cell-h: 3.6rem; --vs-otp-input-gap: .4rem; --vs-otp-input-font-size: 1.6rem; } .vs-otp-input-sep { width: .5rem; } }\n@media (prefers-reduced-motion: reduce) { .vs-otp-input-caret { animation: none; } .vs-otp-input-sweep::before { animation: none; transform: translateX(80%); } }\n@media (forced-colors: active) { .vs-otp-input-frame { border: 1px solid CanvasText; } .vs-otp-input-ring { outline: 2px solid Highlight; } }\n</style>\n\n<script>\n(() => {\n  if (window.__vsOtpInput) return; window.__vsOtpInput = true;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const CHECK = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m5 12.5 4.5 4.5L19 7.5\"/></svg>';\n  let uid = 0;\n\n  function mount(el) {\n    if (el.__vsOtp) return; el.__vsOtp = true;\n    const N = Math.max(2, +(el.dataset.length || 6)), half = N >= 6 ? Math.ceil(N / 2) : 0, id = 'vs-otp-' + (++uid);\n    el.innerHTML = '';\n    const cells = [], digits = [];\n    for (let i = 0; i < N; i++) {\n      if (half && i === half) { const s = document.createElement('span'); s.className = 'vs-otp-input-sep'; s.setAttribute('aria-hidden', 'true'); el.appendChild(s); }\n      const c = document.createElement('span'); c.className = 'vs-otp-input-cell'; c.setAttribute('aria-hidden', 'true');\n      c.innerHTML = '<span class=\"vs-otp-input-frame\"></span><span class=\"vs-otp-input-digit\"></span>';\n      el.appendChild(c); cells.push(c); digits.push(c.lastChild);\n    }\n    const ring = document.createElement('span'); ring.className = 'vs-otp-input-ring'; ring.innerHTML = '<span class=\"vs-otp-input-caret\"></span>';\n    const sweep = document.createElement('span'); sweep.className = 'vs-otp-input-sweep';\n    const seal = document.createElement('span'); seal.className = 'vs-otp-input-seal';\n    const check = document.createElement('span'); check.className = 'vs-otp-input-check'; check.innerHTML = CHECK;\n    const live = document.createElement('span'); live.className = 'vs-otp-input-sr'; live.setAttribute('aria-live', 'polite');\n    [ring, sweep, seal, check].forEach(n => n.setAttribute('aria-hidden', 'true'));\n    const input = document.createElement('input');\n    Object.assign(input, { type: 'text', inputMode: 'numeric', autocomplete: 'one-time-code', maxLength: N, id, spellcheck: false });\n    input.className = 'vs-otp-input-field'; input.setAttribute('pattern', `\\\\d{${N}}`);\n    input.setAttribute('aria-label', el.dataset.label || `${N}-digit code`);\n    if (el.dataset.name) input.name = el.dataset.name;\n    if (el.hasAttribute('data-disabled')) input.disabled = true;\n    el.append(ring, sweep, seal, check, input, live);\n\n    let val = '', state = 'idle', focused = false;\n    const S = { x: { x: 0, v: 0, g: 0, k: 520, c: 34 }, o: { x: 0, v: 0, g: 0, k: 300, c: 28 }, sh: { x: 0, v: 0, g: 0, k: 900, c: 14 } };\n    let raf = 0, last = 0;\n    const tick = now => {\n      const dt = Math.min(.064, (now - last) / 1000 || .016); last = now; let busy = false;\n      for (const k in S) { const s = S[k];\n        if (reduce.matches) { s.x = s.g; s.v = 0; continue; }\n        if (Math.abs(s.v) < .01 && Math.abs(s.x - s.g) < .01) { s.x = s.g; s.v = 0; continue; }\n        busy = true; const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n        for (let i = 0; i < n; i++) { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; } }\n      // the ring stretches along its own velocity and relaxes as it lands\n      const st = Math.min(.32, Math.abs(S.x.v) / 5200);\n      ring.style.transform = `translate3d(${S.x.x.toFixed(2)}px,0,0) scale(${(1 + st).toFixed(3)},${(1 - st * .45).toFixed(3)})`; ring.style.opacity = Math.max(0, Math.min(1, S.o.x)).toFixed(3);\n      el.style.transform = S.sh.x ? `translate3d(${S.sh.x.toFixed(2)}px,0,0)` : '';\n      raf = busy ? requestAnimationFrame(tick) : 0;\n    };\n    const wake = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n\n    const place = jump => {\n      const i = Math.min(N - 1, Math.max(0, input.selectionStart == null ? val.length : input.selectionStart));\n      S.x.g = cells[i].offsetLeft; if (jump) { S.x.x = S.x.g; S.x.v = 0; }\n      ring.classList.toggle('is-full', val.length === N || input.selectionEnd - input.selectionStart === 1 || i < val.length);\n      S.o.g = focused && state !== 'success' && state !== 'checking' ? 1 : 0; wake();\n    };\n    const pop = (i, delay) => {\n      if (reduce.matches) return;\n      digits[i].animate([{ opacity: 0, transform: 'translateY(45%) scale(.7)', filter: 'blur(6px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }],\n        { duration: 520, delay, easing: 'cubic-bezier(.34,1.56,.64,1)', fill: 'backwards' });\n      cells[i].animate([{ transform: 'scale(.94)' }, { transform: 'none' }], { duration: 420, delay, easing: 'cubic-bezier(.34,1.8,.64,1)' });\n    };\n    const drop = (i, ch) => {\n      if (reduce.matches) return;\n      const ghost = document.createElement('span'); ghost.className = 'vs-otp-input-digit'; ghost.textContent = ch;\n      ghost.style.position = 'absolute'; cells[i].appendChild(ghost);\n      ghost.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(35%) scale(.8)', filter: 'blur(4px)' }], { duration: 260, easing: 'cubic-bezier(.4,0,1,1)' }).onfinish = () => ghost.remove();\n    };\n    function render(prev) {\n      let added = 0;\n      for (let i = 0; i < N; i++) {\n        const a = prev[i] || '', b = val[i] || '';\n        if (a === b) continue;\n        digits[i].textContent = b; cells[i].classList.toggle('is-filled', !!b);\n        if (b) pop(i, (added++) * 45); else if (a) drop(i, a);\n      }\n    }\n    function setState(s) {\n      state = s; ['checking', 'error', 'success'].forEach(k => el.classList.toggle('is-' + k, s === k)); place();\n      input.setAttribute('aria-invalid', String(s === 'error'));\n    }\n    function complete() {\n      el.dispatchEvent(new CustomEvent('vs-otp-complete', { bubbles: true, detail: { code: val } }));\n      if (el.dataset.code) { el.vsChecking(); setTimeout(() => val === el.dataset.code ? el.vsSuccess() : el.vsError(), 900); }\n    }\n    input.addEventListener('input', () => {\n      if (state === 'success') return;\n      if (state === 'error') setState('idle');\n      const prev = val, pos = input.selectionStart;\n      val = input.value.replace(/\\D/g, '').slice(0, N);\n      if (input.value !== val) { input.value = val; input.setSelectionRange(Math.min(pos, val.length), Math.min(pos, val.length)); }\n      // typing over a full field keeps the caret moving through the cells\n      if (prev.length === N && val.length === N && pos < N) input.setSelectionRange(pos, pos + 1);\n      render(prev); place();\n      if (val.length === N && prev !== val) complete();\n    });\n    // clicking a cell selects that digit, so the next key replaces it\n    input.addEventListener('click', e => {\n      if (state === 'success') return;\n      const r = el.getBoundingClientRect(); let i = cells.findIndex(c => { const b = c.getBoundingClientRect(); return e.clientX < b.right + 4; });\n      if (i < 0) i = N - 1;\n      if (i < val.length) input.setSelectionRange(i, i + 1); else input.setSelectionRange(val.length, val.length);\n      place();\n    });\n    input.addEventListener('keydown', e => {\n      if (e.key === 'Backspace' && input.selectionStart === input.selectionEnd && input.selectionStart === 0 && val) e.preventDefault();\n      if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.shiftKey) {\n        e.preventDefault();\n        const i = Math.max(0, Math.min(val.length, (input.selectionStart) + (e.key === 'ArrowLeft' ? -1 : (input.selectionEnd > input.selectionStart ? 1 : 1))));\n        if (i < val.length) input.setSelectionRange(i, i + 1); else input.setSelectionRange(val.length, val.length);\n        place();\n      }\n    });\n    document.addEventListener('selectionchange', () => { if (document.activeElement === input) place(); });\n    input.addEventListener('focus', () => { focused = true; place(true); });\n    input.addEventListener('blur', () => { focused = false; place(); });\n    new ResizeObserver(() => place(true)).observe(el);\n\n    el.vsChecking = () => { setState('checking'); live.textContent = 'Checking code'; };\n    el.vsError = (msg) => {\n      setState('error'); live.textContent = msg || 'That code is not right';\n      S.sh.v = reduce.matches ? 0 : -520; wake();\n      setTimeout(() => { if (state !== 'error') return; const prev = val; val = ''; input.value = ''; render(prev); setState('idle'); input.setSelectionRange(0, 0); place(); }, 900);\n    };\n    el.vsSuccess = () => {\n      setState('success'); live.textContent = 'Code accepted'; input.blur(); input.readOnly = true;\n      // slide the cells together into one capsule, then seal it with a check\n      const first = cells[0].offsetLeft, w = cells[0].offsetWidth, total = N * w, rowW = el.offsetWidth, start = (rowW - total) / 2 - 26;\n      const e = 'cubic-bezier(.16,1,.3,1)', d = reduce.matches ? 0 : 1;\n      cells.forEach((c, i) => { const dx = start + i * w - c.offsetLeft;\n        c.animate([{ transform: 'none' }, { transform: `translateX(${dx}px)` }], { duration: 700 * d, delay: 60 * d, easing: e, fill: 'forwards' }); });\n      seal.style.left = start + 'px'; seal.style.width = (total + 60) + 'px';\n      seal.animate([{ opacity: 0, transform: 'scale(.92)' }, { opacity: 1, transform: 'none' }], { duration: 700 * d, delay: 200 * d, easing: e, fill: 'forwards' });\n      check.style.right = (rowW - (start + total + 60)) + 10 + 'px';\n      check.animate([{ opacity: 0, transform: 'scale(.3) rotate(-30deg)' }, { opacity: 1, transform: 'none' }], { duration: 620 * d, delay: 520 * d, easing: 'cubic-bezier(.34,1.56,.64,1)', fill: 'forwards' });\n      place();\n    };\n    el.vsReset = () => {\n      const prev = val; val = ''; input.value = ''; input.readOnly = false; setState('idle');\n      cells.forEach(c => c.getAnimations().forEach(a => a.cancel())); [seal, check].forEach(n => n.getAnimations().forEach(a => a.cancel()));\n      render(prev); place(true);\n    };\n    Object.defineProperty(el, 'value', { get: () => val, configurable: true });\n  }\n  const init = () => document.querySelectorAll('.vs-otp-input').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsOtpInput = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Buttons & inputs",
    "tags": [
      "otp",
      "one-time code",
      "2fa",
      "verification",
      "input",
      "form",
      "auth",
      "pin"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#3dffa8",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/otp-input/index.html",
    "thumbnail": "https://vividsites.app/api/component/otp-input/thumb.jpg",
    "video": "https://vividsites.app/api/component/otp-input/thumb.mp4",
    "page": "https://vividsites.app/c/otp-input"
  }
}