{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "float-input",
  "type": "registry:block",
  "title": "Float Input",
  "description": "A text field whose label floats up on a spring, rewards early with a drawn success tick and punishes late: errors wait for blur, then the field shakes on a decaying spring. Supports async checks with a spinner, a character counter, leading icons and a line variant.",
  "docs": "Float Input was added to public/vividsites/float-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/float-input",
  "categories": [
    "ui",
    "forms"
  ],
  "files": [
    {
      "path": "components/vividsites/float-input.html",
      "type": "registry:file",
      "target": "public/vividsites/float-input.html",
      "content": "<!--\nvs-float-input: a text field whose label floats up on a spring when you focus or fill it.\n  It validates the way good forms do: it rewards early (a tick draws in the moment the value\n  is valid) and punishes late (errors wait for blur or submit, then the field shakes on a\n  decaying spring and the message rises in). Once a field shows an error it re-checks live\n  as you type, so the red clears the moment it is fixed. Async checks show a spinner.\n\n  Markup (one per field; the label wraps the input, so no ids are needed):\n    <div class=\"vs-float-input\">\n      <label class=\"vs-float-input-field\">\n        <input type=\"email\" required autocomplete=\"email\" placeholder=\"name@studio.co\">\n        <span class=\"vs-float-input-label\">Work email</span>\n      </label>\n    </div>\n  Optional: an <svg class=\"vs-float-input-lead\"> inside the label as a leading icon.\n  Validation uses the input's native constraints: required, type (email, url…), minlength,\n    maxlength (also shows a counter), pattern. Custom text: data-msg-required, data-msg-type,\n    data-msg-short, data-msg-pattern. data-ok=\"Looks good\" shows a success line.\n  Root attributes:\n    data-vs-float-input-variant  \"box\" (default) | \"line\" (underline; focus bar grows from where you clicked)\n    data-vs-float-input-theme    \"light\" (default) | \"dark\"\n  JS API on the root element:  el.vsFloatInput.validator = async (value) => \"error text\" | \"\"\n    el.vsFloatInput.validate()  el.vsFloatInput.setError(text)  el.vsFloatInput.reset()\n  Events: \"vs-float-input:state\" bubbles with detail.state = idle | pending | valid | error.\n  Customise with the CSS variables below. Reduced motion: no springs, no shake.\n-->\n<style>\n.vs-float-input {\n  --vs-float-input-size: 1rem;                 /* scales the whole field */\n  --vs-float-input-font: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;  /* or inherit */\n  --vs-float-input-width: 26rem;               /* max width; none for full width */\n  --vs-float-input-accent: #2f5bff;            /* focus ring + caret */\n  --vs-float-input-error: #e2444a;\n  --vs-float-input-success: #16945c;\n  --vs-float-input-bg: #ffffff;\n  --vs-float-input-ink: #14161b;\n  --vs-float-input-muted: rgba(20,22,27,.5);\n  --vs-float-input-line: rgba(20,22,27,.2);\n  --vs-float-input-radius: 12px;\n  --vs-float-input-speed: 1;                   /* spring speed multiplier */\n\n  --vs-fi-c: var(--vs-float-input-accent);     /* current state colour (internal) */\n  --vs-fi-p: 0;                                /* label float 0 → 1, written by the script */\n  display: block; position: relative; max-width: var(--vs-float-input-width); font-family: var(--vs-float-input-font);\n  font-size: var(--vs-float-input-size); color: var(--vs-float-input-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.vs-float-input *, .vs-float-input *::before, .vs-float-input *::after { box-sizing: border-box; }\n.vs-float-input[data-vs-float-input-theme=\"dark\"] {\n  --vs-float-input-bg: rgba(255,255,255,.045);\n  --vs-float-input-ink: #f1f2f4;\n  --vs-float-input-muted: rgba(241,242,244,.48);\n  --vs-float-input-line: rgba(255,255,255,.14);\n  --vs-float-input-error: #ff6b6b;\n  --vs-float-input-success: #3fd08c;\n}\n.vs-float-input[data-state=\"error\"] { --vs-fi-c: var(--vs-float-input-error); }\n.vs-float-input[data-state=\"valid\"] { --vs-fi-c: var(--vs-float-input-success); }\n\n/* the box */\n.vs-float-input-field {\n  position: relative; display: flex; align-items: center; height: 3.5em; cursor: text;\n  border-radius: var(--vs-float-input-radius); background: var(--vs-float-input-bg);\n  box-shadow: inset 0 0 0 1px var(--vs-float-input-line), 0 1px 2px rgba(10,12,20,.06), 0 4px 12px -6px rgba(10,12,20,.08);\n  transition: box-shadow .2s cubic-bezier(.2,0,0,1), background-color .2s cubic-bezier(.2,0,0,1);\n  will-change: transform; -webkit-tap-highlight-color: transparent;\n}\n.vs-float-input-field::after { /* focus / state ring, drawn outside so it never shifts layout */\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;\n  box-shadow: 0 0 0 1.5px var(--vs-fi-c), 0 0 0 5px color-mix(in srgb, var(--vs-fi-c) 16%, transparent);\n  opacity: 0; transition: opacity .2s cubic-bezier(.2,0,0,1);\n}\n.vs-float-input-field:hover { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-float-input-ink) 34%, transparent), 0 1px 2px rgba(10,12,20,.06), 0 4px 12px -6px rgba(10,12,20,.1); }\n.vs-float-input.is-focus .vs-float-input-field::after { opacity: 1; }\n.vs-float-input[data-state=\"error\"] .vs-float-input-field::after { opacity: 1; box-shadow: 0 0 0 1.5px var(--vs-fi-c), 0 0 0 5px color-mix(in srgb, var(--vs-fi-c) 13%, transparent); }\n.vs-float-input[data-state=\"error\"]:not(.is-focus) .vs-float-input-field::after { box-shadow: 0 0 0 1px var(--vs-fi-c); }\n.vs-float-input[data-state=\"error\"] .vs-float-input-field { background: color-mix(in srgb, var(--vs-float-input-error) 4%, var(--vs-float-input-bg)); }\n\n.vs-float-input-field input {\n  all: unset; box-sizing: border-box; flex: 1; min-width: 0; height: 100%;\n  padding: 1.42em 3em .5em 1em; font: inherit; font-size: 1em; line-height: 1.2; letter-spacing: -.005em;\n  color: var(--vs-float-input-ink); caret-color: var(--vs-float-input-accent); cursor: text;\n}\n.vs-float-input-field input::placeholder { color: var(--vs-float-input-muted); opacity: 0; transition: opacity .15s; }\n.vs-float-input.is-focus input::placeholder { opacity: .7; transition: opacity .25s .12s; }\n.vs-float-input-field input:-webkit-autofill { -webkit-text-fill-color: var(--vs-float-input-ink); transition: background-color 9999s; animation: vs-float-input-af .01s; }\n@keyframes vs-float-input-af { from { opacity: 1; } }\n\n/* the label: moved by --vs-fi-p (a spring). 0 = resting in the middle, 1 = floated to the top */\n.vs-float-input-label {\n  position: absolute; left: 1em; top: 50%; pointer-events: none; white-space: nowrap;\n  max-width: calc(100% - 4em); overflow: hidden; text-overflow: ellipsis;\n  color: color-mix(in srgb, var(--vs-float-input-muted) calc(100% - var(--vs-fi-f, 0) * 100%), var(--vs-fi-c));\n  transform-origin: 0 50%;\n  transform: translateY(calc(-50% - var(--vs-fi-p) * .72em)) scale(calc(1 - var(--vs-fi-p) * .24));\n  will-change: transform;\n}\n.vs-float-input.is-focus, .vs-float-input[data-state=\"error\"], .vs-float-input[data-state=\"valid\"] { --vs-fi-f: 1; }\n.vs-float-input[data-state=\"valid\"]:not(.is-focus) { --vs-fi-f: 0; }\n.vs-float-input-label { transition: color .2s cubic-bezier(.2,0,0,1); }\n\n/* leading icon */\n.vs-float-input-lead { position: absolute; left: 1em; top: 50%; width: 1.15em; height: 1.15em; margin-top: -.575em; color: var(--vs-float-input-muted); transition: color .2s; pointer-events: none; }\n.vs-float-input-field:has(.vs-float-input-lead) input { padding-left: 2.8em; }\n.vs-float-input-field:has(.vs-float-input-lead) .vs-float-input-label { left: 2.8em; }\n.vs-float-input-field:has(.vs-float-input-lead) + .vs-float-input-msg { padding-left: 3.446em; }\n.vs-float-input.is-focus .vs-float-input-lead { color: var(--vs-fi-c); }\n\n/* status glyphs on the right */\n.vs-float-input-status { position: absolute; right: .95em; top: 50%; width: 1.25em; height: 1.25em; margin-top: -.625em; pointer-events: none; }\n.vs-float-input-status > * { position: absolute; inset: 0; width: 100%; height: 100%; }\n.vs-float-input-ok, .vs-float-input-bad { opacity: 0; transform: scale(.4); transition: opacity .16s, transform .16s cubic-bezier(.2,0,0,1); }\n.vs-float-input-ok circle { fill: var(--vs-float-input-success); }\n.vs-float-input-ok path { fill: none; stroke: #fff; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 12; stroke-dashoffset: 12; transition: stroke-dashoffset .1s; }\n.vs-float-input[data-state=\"valid\"] .vs-float-input-ok { opacity: 1; transform: none; transition: opacity .12s, transform .5s cubic-bezier(.34,1.7,.5,1); }\n.vs-float-input[data-state=\"valid\"] .vs-float-input-ok path { stroke-dashoffset: 0; transition: stroke-dashoffset .34s cubic-bezier(.65,0,.35,1) .12s; }\n.vs-float-input-bad circle { fill: var(--vs-float-input-error); }\n.vs-float-input-bad path { fill: none; stroke: #fff; stroke-width: 2.1; stroke-linecap: round; }\n.vs-float-input[data-state=\"error\"] .vs-float-input-bad { opacity: 1; transform: none; transition: opacity .12s, transform .5s cubic-bezier(.34,1.7,.5,1); }\n.vs-float-input-spin { border-radius: 50%; border: 2px solid color-mix(in srgb, var(--vs-float-input-ink) 14%, transparent); border-top-color: var(--vs-float-input-accent); opacity: 0; transform: scale(.5); transition: opacity .15s, transform .2s; }\n.vs-float-input[data-state=\"pending\"] .vs-float-input-spin { opacity: 1; transform: none; animation: vs-float-input-spin .7s linear infinite; }\n@keyframes vs-float-input-spin { to { transform: rotate(1turn); } }\n\n/* message line: space is reserved, so a message never pushes the page */\n.vs-float-input-msg { display: flex; gap: 1em; min-height: 2.2em; padding: .5em .15em .3em 1.231em; /* 1em of the field, in this line's smaller em */ font-size: .8125em; line-height: 1.3; }\n.vs-float-input-text { flex: 1; color: var(--vs-fi-c); opacity: 0; transform: translateY(-.35em); transition: opacity .15s, transform .15s; }\n.vs-float-input-text.is-on { opacity: 1; transform: none; transition: opacity .22s cubic-bezier(.2,0,0,1), transform .45s cubic-bezier(.34,1.56,.64,1); }\n.vs-float-input-count { color: var(--vs-float-input-muted); font-variant-numeric: tabular-nums; transition: color .2s; }\n.vs-float-input-count.is-max { color: var(--vs-float-input-error); }\n\n/* line variant */\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-field { border-radius: 0; background: none; box-shadow: inset 0 -1px 0 var(--vs-float-input-line); }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-field:hover { box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--vs-float-input-ink) 36%, transparent); }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-field::after {\n  inset: auto 0 0 0; height: 2px; border-radius: 2px; box-shadow: none; background: var(--vs-fi-c);\n  transform: scaleX(0); transform-origin: var(--vs-fi-ox, 50%) 50%; opacity: 1;\n  transition: transform .5s cubic-bezier(.16,1,.3,1), background-color .2s;\n}\n.vs-float-input[data-vs-float-input-variant=\"line\"].is-focus .vs-float-input-field::after,\n.vs-float-input[data-vs-float-input-variant=\"line\"][data-state=\"error\"] .vs-float-input-field::after { transform: scaleX(1); }\n.vs-float-input[data-vs-float-input-variant=\"line\"] input { padding-left: .1em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-label { left: .1em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-status { right: .1em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-msg { padding-left: .123em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-lead { left: .1em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-field:has(.vs-float-input-lead) input { padding-left: 1.85em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-field:has(.vs-float-input-lead) .vs-float-input-label { left: 1.85em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"] .vs-float-input-field:has(.vs-float-input-lead) + .vs-float-input-msg { padding-left: 2.277em; }\n.vs-float-input[data-vs-float-input-variant=\"line\"][data-state=\"error\"] .vs-float-input-field { background: none; }\n\n/* disabled */\n.vs-float-input:has(input:disabled) { opacity: .5; }\n.vs-float-input:has(input:disabled) .vs-float-input-field { cursor: not-allowed; box-shadow: inset 0 0 0 1px var(--vs-float-input-line); }\n</style>\n\n<div class=\"vs-float-input\">\n  <label class=\"vs-float-input-field\">\n    <svg class=\"vs-float-input-lead\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"7\" r=\"3.2\"/><path d=\"M3.8 16.6c1.2-2.9 3.5-4.3 6.2-4.3s5 1.4 6.2 4.3\"/></svg>\n    <input type=\"text\" required minlength=\"2\" autocomplete=\"name\" placeholder=\"As it appears on your ID\">\n    <span class=\"vs-float-input-label\">Full name</span>\n  </label>\n</div>\n<div class=\"vs-float-input\">\n  <label class=\"vs-float-input-field\">\n    <svg class=\"vs-float-input-lead\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"10\" r=\"3\"/><path d=\"M13 10v1.3a2.4 2.4 0 0 0 4.8 0V10A7.8 7.8 0 1 0 14.6 16.3\"/></svg>\n    <input type=\"email\" required autocomplete=\"email\" placeholder=\"name@studio.co\" data-msg-type=\"That address is missing an @ or a domain\">\n    <span class=\"vs-float-input-label\">Work email</span>\n  </label>\n</div>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  let uid = 0;\n  const GLYPHS = '<svg class=\"vs-float-input-ok\" viewBox=\"0 0 20 20\"><circle cx=\"10\" cy=\"10\" r=\"10\"/><path d=\"M6.1 10.3l2.7 2.7 5.2-5.6\"/></svg>' +\n    '<svg class=\"vs-float-input-bad\" viewBox=\"0 0 20 20\"><circle cx=\"10\" cy=\"10\" r=\"10\"/><path d=\"M10 5.6v5.3M10 14.1v.2\"/></svg><i class=\"vs-float-input-spin\"></i>';\n\n  function init(root) {\n    if (root.vsFloatInput) return;\n    const field = root.querySelector('.vs-float-input-field');\n    const input = field.querySelector('input, textarea');\n    // inject status glyphs and the message line when the markup leaves them out\n    let status = root.querySelector('.vs-float-input-status');\n    if (!status) { status = document.createElement('span'); status.className = 'vs-float-input-status'; status.setAttribute('aria-hidden', 'true'); status.innerHTML = GLYPHS; field.appendChild(status); }\n    let msg = root.querySelector('.vs-float-input-msg');\n    if (!msg) { msg = document.createElement('div'); msg.className = 'vs-float-input-msg'; msg.innerHTML = '<span class=\"vs-float-input-text\" aria-live=\"polite\"></span>'; root.appendChild(msg); }\n    const text = msg.querySelector('.vs-float-input-text');\n    text.id = text.id || 'vs-float-input-msg-' + (++uid);\n    input.setAttribute('aria-describedby', ((input.getAttribute('aria-describedby') || '') + ' ' + text.id).trim());\n    let count = null;\n    if (input.maxLength > 0) { count = document.createElement('span'); count.className = 'vs-float-input-count'; msg.appendChild(count); }\n\n    // springs: p = label float, x = shake offset (px). Semi-implicit Euler, runs only while moving.\n    const S = { p: { x: 0, v: 0, t: 0, k: 520, c: 34 }, s: { x: 0, v: 0, t: 0, k: 900, c: 15 } };\n    let raf = 0, last = 0;\n    const speed = () => parseFloat(getComputedStyle(root).getPropertyValue('--vs-float-input-speed')) || 1;\n    const paint = () => { root.style.setProperty('--vs-fi-p', S.p.x.toFixed(4)); field.style.transform = Math.abs(S.s.x) > .01 ? `translateX(${S.s.x.toFixed(2)}px)` : ''; };\n    const tick = (now) => {\n      const dt = Math.min(.032, (now - last) / 1000 || .016) * speed(); last = now;\n      let moving = false;\n      for (const k in S) {\n        const s = S[k];\n        for (let i = 0; i < 2; i++) { const h = dt / 2; s.v += (-s.k * (s.x - s.t) - s.c * s.v) * h; s.x += s.v * h; }\n        if (Math.abs(s.v) > .002 || Math.abs(s.x - s.t) > .0005) moving = true; else { s.x = s.t; s.v = 0; }\n      }\n      paint();\n      raf = moving ? requestAnimationFrame(tick) : 0;\n    };\n    const kick = () => { if (reduce.matches) { for (const k in S) { S[k].x = S[k].t; S[k].v = 0; } paint(); return; } if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n\n    const filled = () => input.value !== '' || input.matches(':-webkit-autofill');\n    const float = () => { S.p.t = (root.classList.contains('is-focus') || filled()) ? 1 : 0; kick(); };\n    const shake = () => { S.s.v = -(root.offsetWidth > 360 ? 560 : 420); kick(); };\n\n    let state = 'idle', token = 0, timer = 0;\n    const set = (next, message = '') => {\n      const changed = next !== state; state = next;\n      root.dataset.state = next;\n      input.setAttribute('aria-invalid', next === 'error');\n      text.textContent = message;\n      text.classList.toggle('is-on', !!message);\n      if (changed) root.dispatchEvent(new CustomEvent('vs-float-input:state', { bubbles: true, detail: { state: next, message } }));\n    };\n    const nativeError = () => {\n      const v = input.validity, d = input.dataset;\n      if (v.valid) return '';\n      if (v.valueMissing) return d.msgRequired || 'This field is required';\n      if (v.typeMismatch) return d.msgType || (input.type === 'email' ? 'Enter a valid email address' : 'Enter a valid ' + input.type);\n      if (v.tooShort) return d.msgShort || `Use at least ${input.minLength} characters`;\n      if (v.patternMismatch) return d.msgPattern || input.title || 'That format isn’t right';\n      return input.validationMessage;\n    };\n    // minlength isn't enforced by the browser for script-typed values, so check it ourselves too\n    const syncError = () => nativeError() || (input.minLength > 0 && input.value.length && input.value.length < input.minLength ? (input.dataset.msgShort || `Use at least ${input.minLength} characters`) : '');\n\n    const runAsync = async (value, loud) => {\n      const api = root.vsFloatInput;\n      if (!api.validator) { set('valid', input.dataset.ok || ''); return; }\n      const my = ++token; set('pending');\n      let err = '';\n      try { err = (await api.validator(value)) || ''; } catch (e) { err = 'Couldn’t check that, try again'; }\n      if (my !== token || input.value !== value) return;\n      if (err) { set('error', err); if (loud) shake(); } else set('valid', input.dataset.ok || '');\n    };\n\n    const validate = (loud) => {\n      clearTimeout(timer); token++;\n      const err = syncError();\n      if (!input.value && !loud) { set('idle'); return true; }\n      if (err) { set('error', err); if (loud) shake(); return false; }\n      runAsync(input.value, loud);\n      return true;\n    };\n\n    const counter = () => {\n      if (!count) return;\n      count.textContent = input.value.length + ' / ' + input.maxLength;\n      count.classList.toggle('is-max', input.value.length >= input.maxLength);\n    };\n\n    input.addEventListener('focus', () => { root.classList.add('is-focus'); float(); });\n    input.addEventListener('blur', () => {\n      root.classList.remove('is-focus'); float();\n      if (input.value) validate(state !== 'error' || !!syncError()); // punish late: only now\n      else if (state !== 'error') set('idle');\n    });\n    input.addEventListener('input', () => {\n      float(); counter();\n      const err = syncError();\n      clearTimeout(timer); token++;\n      if (!input.value) { set(state === 'error' && input.required ? 'error' : 'idle', state === 'error' && input.required ? nativeError() : ''); return; }\n      if (err) { if (state === 'error') set('error', err); else set('idle'); return; } // reward early, punish late\n      if (root.vsFloatInput.validator) { set('pending'); timer = setTimeout(() => runAsync(input.value, false), 420); }\n      else set('valid', input.dataset.ok || '');\n    });\n    // form submit / checkValidity: show our message instead of the browser bubble\n    input.addEventListener('invalid', (e) => { e.preventDefault(); set('error', syncError() || nativeError()); shake(); });\n    input.addEventListener('animationstart', (e) => { if (e.animationName === 'vs-float-input-af') float(); });\n    // line variant: the focus bar grows from where you pressed\n    field.addEventListener('pointerdown', (e) => {\n      const r = field.getBoundingClientRect();\n      root.style.setProperty('--vs-fi-ox', ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%');\n    });\n    input.form && input.form.addEventListener('reset', () => setTimeout(() => api.reset()));\n\n    const api = root.vsFloatInput = {\n      validator: null,\n      validate: () => validate(true),\n      setError: (m) => { set('error', m); shake(); },\n      reset: () => { clearTimeout(timer); token++; input.value = ''; counter(); set('idle'); float(); },\n      get state() { return state; },\n    };\n    root.dataset.state = 'idle';\n    counter();\n    S.p.t = S.p.x = filled() || document.activeElement === input ? 1 : 0;\n    if (document.activeElement === input) root.classList.add('is-focus');\n    paint();\n  }\n\n  const boot = () => document.querySelectorAll('.vs-float-input').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n  window.vsFloatInputInit = boot;\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Forms",
    "tags": [
      "input",
      "text field",
      "floating label",
      "validation",
      "form",
      "error",
      "success",
      "shake"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#2f5bff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/float-input/index.html",
    "thumbnail": "https://vividsites.app/api/component/float-input/thumb.jpg",
    "video": "https://vividsites.app/api/component/float-input/thumb.mp4",
    "page": "https://vividsites.app/c/float-input"
  }
}