{
  "id": "kinetic-type-interactive",
  "kind": "element",
  "title": "Kinetic type interactive",
  "summary": "Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.",
  "url": "https://enrichr.dev/elements/kinetic-type-interactive.html",
  "section": "text",
  "techniques": [
    "text-animation",
    "pointer-interaction",
    "scroll-driven"
  ],
  "stack": [
    "Vanilla JS",
    "CSS"
  ],
  "frameworks": [
    "React",
    "Next.js",
    "Vanilla"
  ],
  "mood": [
    "playful",
    "tactile"
  ],
  "intents": [
    "magnetic text cursor effect",
    "variable font weight follows cursor",
    "scroll fill text animation",
    "letters scatter on hover",
    "text distortion follows mouse"
  ],
  "status": "tested",
  "params": [
    {
      "key": "effect",
      "label": "Study",
      "type": "select",
      "options": [
        [
          "magnet",
          "16 Magnetic pull"
        ],
        [
          "scrub",
          "17 Scroll scrub"
        ],
        [
          "scatter",
          "18 Hover scatter"
        ],
        [
          "pressure",
          "19 Weight morph"
        ],
        [
          "distort",
          "20 Cursor distort"
        ]
      ],
      "value": "magnet"
    },
    {
      "key": "text",
      "label": "Text",
      "type": "text",
      "value": "BEND THE LINE"
    },
    {
      "key": "speed",
      "label": "Speed (1 = original timing)",
      "type": "range",
      "min": 0.25,
      "max": 3,
      "step": 0.05,
      "value": 1
    },
    {
      "key": "size",
      "label": "Text size (px)",
      "type": "range",
      "min": 16,
      "max": 120,
      "step": 1,
      "value": 42
    },
    {
      "key": "color",
      "label": "Text colour",
      "type": "color",
      "value": "#fbf8f1"
    },
    {
      "key": "accent",
      "label": "Accent (label and focus ring)",
      "type": "color",
      "value": "#e6ff55"
    },
    {
      "key": "background",
      "label": "Background",
      "type": "color",
      "value": "#0c1e18"
    },
    {
      "key": "label",
      "label": "Specimen label",
      "type": "select",
      "options": [
        [
          "1",
          "Show"
        ],
        [
          "0",
          "Hide"
        ]
      ],
      "value": "1"
    }
  ],
  "license": "Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.",
  "contract": "function mount(root, opts) -> { update(partialOpts), destroy() }; root must contain the element markup",
  "exports": {
    "claude": "Add the \"Kinetic type interactive\" element to this project.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSteps:\n1. Look at the project first to see its framework and where components, styles and static files live. Follow its conventions.\n2. Fetch https://enrichr.dev/elements/kinetic-type-interactive.json if you can read URLs (fields: source.markup, source.css, source.js, params); otherwise use the source at the end of this message.\n3. Add the files, mount it where I tell you (default: the top of the home page), and pass the settings below as the opts.\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n5. Run the dev server and check it. with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "chatgpt": "I want the \"Kinetic type interactive\" element on my website. Below is its complete source. Please:\n1. Build one self-contained index.html that I can open locally to see it working (put the CSS in a <style> tag and the JavaScript in a <script> tag, with the markup in the body).\n2. Then explain, step by step, which parts go where in my own site, and how to mount it. My stack is: [tell me yours, or say \"plain HTML\"].\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "cursor": "Add the \"Kinetic type interactive\" element to this codebase.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nUse Composer / agent mode. First search the repo for how components, styles and scripts are organised, then add the files in the matching places, mount it on the page I name, and run the app to confirm it works. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nOptional: save this as .cursor/rules/enrichr-kinetic-type-interactive.mdc so the rule stays with the project:\n---\ndescription: How to use the Enrichr Kinetic type interactive element\nalwaysApply: false\n---\nmount(root, opts) expects the element markup inside root and returns { update, destroy }. Always call destroy() on unmount and never edit the ef- class names.",
    "v0": "Create a Next.js (App Router) client component for the \"Kinetic type interactive\" element. Tailwind is available but this component needs no utility classes.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- kinetic-type-interactive.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nUse this component as the wrapper (adjust the import paths):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./kinetic-type-interactive\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function KineticTypeInteractive(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRender it on the home page so I can see it. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "lovable": "Add the \"Kinetic type interactive\" element to this React + Vite + TypeScript + Tailwind project. Do not install any packages.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- kinetic-type-interactive.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nWrapper component (src/components/KineticTypeInteractive.tsx):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./kinetic-type-interactive\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function KineticTypeInteractive(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRemove the \"use client\" line if it is not needed. Show it on the home page. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "bolt": "Create a small Vite project (vanilla JavaScript, no framework unless I already have one) that shows the \"Kinetic type interactive\" element.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, then a script tag), style.css (the CSS below), kinetic-type-interactive.js (the JavaScript below) and main.js that sets the container's innerHTML to the markup and calls mount(container, opts) with the settings below. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "replit": "Create a new HTML, CSS and JavaScript Repl that shows the \"Kinetic type interactive\" element. No packages, no server code.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, link style.css, load kinetic-type-interactive.js and then main.js), style.css (the CSS below), kinetic-type-interactive.js (the JavaScript below), main.js (sets the container's innerHTML to the markup, then calls mount(container, opts) with the settings below). Press Run to preview. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "figma": "Designer handoff notes for the \"Kinetic type interactive\" element (Enrichr Library). Figma cannot run this JavaScript inside a design file, so use it as a motion reference and hand the code to a developer.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nWhat it does: The line is split into letter spans and every pointer move measures each letter where it is drawn right now. Magnetic pull moves a letter toward the pointer by 35% of the distance times min(1, 90 / distance), easing over 0.15 s, so near letters lean hard and far ones barely move. Weight morph sets each letter's weight to 900 minus 6 for every pixel of horizontal distance, never below 300, through font-variation-settings on the variable brand font (its axis tops out at 800). Cursor distort skews letters within 120 px up to 18 degrees away from the pointer and scales them up to 140%. Hover scatter throws every letter up to 40 px sideways, 30 px up or down and 40 degrees round, and springs it back on leave over 0.5 s on cubic-bezier(.2,.9,.3,1.1). Scroll scrub fills the word from the left with a hard-edged gradient whose stop follows how far the element has travelled up the screen, read from getBoundingClientRect on a passive scroll listener. Speed divides the transition times.\nAccessibility: The line is visually hidden text for screen readers and the moving letters are hidden from them. The element is focusable: focus plays the hover scatter (Enter or Space scatters again), and for the cursor studies the arrow keys move a focus point across the text; Escape lets go. With prefers-reduced-motion letters never move: hover and focus only change the colour, while the weight morph and the scroll fill, which move nothing, still work. Touch works by dragging sideways; vertical swipes still scroll the page.\n\nTunable settings:\n- Study: select (16 Magnetic pull / 17 Scroll scrub / 18 Hover scatter / 19 Weight morph / 20 Cursor distort), default magnet\n- Text: text, default BEND THE LINE\n- Speed (1 = original timing): range 0.25 to 3, step 0.05, default 1\n- Text size (px): range 16 to 120, step 1, default 42\n- Text colour: color, default #fbf8f1\n- Accent (label and focus ring): color, default #e6ff55\n- Background: color, default #0c1e18\n- Specimen label: select (Show / Hide), default 1\n\nCurrent settings:\n{{SETTINGS}}\n\nFor the developer: Element contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nIn Figma:\n- Draw the resting state as a frame and the end state as a second frame; link them with Smart Animate to show the motion in a prototype.\n- Put the settings above in the frame's notes or Dev Mode annotations so the developer matches them.\n- Link https://enrichr.dev/elements/kinetic-type-interactive.html in the handoff: the live demo and the copy-ready code are there. Developers can paste the prompts for Claude, Cursor or v0 from the Export menu.\nNothing is copied from another designer's file; this is original code from Enrichr.",
    "framer": "Add the \"Kinetic type interactive\" element to Framer as a code component. Framer code components are React, so wrap the vanilla code like this. Check it in the Framer editor, as Framer's component API can change.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. In Framer: Assets > Code > New component. Name it KineticTypeInteractive.\n2. Paste the JavaScript below into a separate code file named kinetic-type-interactive.js in the same project, with  export { mount };  and  export const markup = \"<the markup string>\";  added. Put the CSS into the component as a <style> tag.\n3. Component:\n```tsx\nimport { addPropertyControls, ControlType } from \"framer\"\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./kinetic-type-interactive\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function KineticTypeInteractive(props: any) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n\naddPropertyControls(KineticTypeInteractive, {\n  effect: { type: ControlType.Enum, title: \"Study\", options: [\"magnet\",\"scrub\",\"scatter\",\"pressure\",\"distort\"], optionTitles: [\"16 Magnetic pull\",\"17 Scroll scrub\",\"18 Hover scatter\",\"19 Weight morph\",\"20 Cursor distort\"], defaultValue: \"magnet\" },\n  text: { type: ControlType.String, title: \"Text\", defaultValue: \"BEND THE LINE\" },\n  speed: { type: ControlType.Number, title: \"Speed\", min: 0.25, max: 3, step: 0.05, defaultValue: 1 },\n  size: { type: ControlType.Number, title: \"Text size\", min: 16, max: 120, step: 1, defaultValue: 42 },\n  color: { type: ControlType.Color, title: \"Text colour\", defaultValue: \"#fbf8f1\" },\n  accent: { type: ControlType.Color, title: \"Accent (label and focus ring)\", defaultValue: \"#e6ff55\" },\n  background: { type: ControlType.Color, title: \"Background\", defaultValue: \"#0c1e18\" },\n  label: { type: ControlType.Enum, title: \"Specimen label\", options: [\"1\",\"0\"], optionTitles: [\"Show\",\"Hide\"], defaultValue: \"1\" },\n})\n```\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "webflow": "Add the \"Kinetic type interactive\" element to a Webflow site with an Embed element. Test it on the published or preview site: scripts in embeds may not run inside the Designer canvas.\n\nThis is the \"Kinetic type interactive\" element from the Enrichr Library: Five text studies that answer you: letters pulled toward the pointer, a word filled by scroll, a hover scatter, a weight that swells near the cursor and a fisheye skew.\nPage: https://enrichr.dev/elements/kinetic-type-interactive.html\nMachine-readable spec: https://enrichr.dev/elements/kinetic-type-interactive.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. Add an Embed element where it should appear. Paste a <style> block with the CSS, one container <div id=\"enrichr-kinetic-type-interactive\"></div>, and a <script> block with the JavaScript followed by:\n   mount(document.getElementById(\"enrichr-kinetic-type-interactive\"), { ...your settings... });\n   (set the container's innerHTML to the markup first, as in the contract).\n2. Embed elements have a size limit (check Webflow's current number). If the code is longer, host the JS and CSS as files and link them from Custom code in the page settings, then keep only the container and the mount call in the Embed.\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}"
  },
  "exportTools": [
    "claude",
    "chatgpt",
    "cursor",
    "v0",
    "lovable",
    "bolt",
    "replit",
    "figma",
    "framer",
    "webflow"
  ],
  "accessibility": "The line is visually hidden text for screen readers and the moving letters are hidden from them. The element is focusable: focus plays the hover scatter (Enter or Space scatters again), and for the cursor studies the arrow keys move a focus point across the text; Escape lets go. With prefers-reduced-motion letters never move: hover and focus only change the colour, while the weight morph and the scroll fill, which move nothing, still work. Touch works by dragging sideways; vertical swipes still scroll the page.",
  "performance": "Each pointer move reads a few dozen letter boxes and writes one transform or weight each, with no animation loop. The scroll listener is passive and only does work while the element is on screen.",
  "dependencies": [],
  "codepen": "https://codepen.io/Enrichr-Dev/pen/emvbjbz",
  "source": {
    "markup": "<div class=\"ef-kti\" data-fx=\"magnet\" data-label=\"1\" tabindex=\"0\" role=\"group\" aria-label=\"Interactive type study. With the keyboard, focus it and use the arrow keys to move the focus point.\">\n  <div class=\"ef-kti__head\">\n    <span class=\"ef-kti__num\" aria-hidden=\"true\">16</span>\n    <span class=\"ef-kti__meta\"><span class=\"ef-kti__name\">Magnetic pull</span><span class=\"ef-kti__desc\">Letters lean toward your cursor as it nears.</span></span>\n  </div>\n  <div class=\"ef-kti__stage\"><span class=\"ef-kti__demo\">BEND THE LINE</span></div>\n  <span class=\"ef-kti__hint\" aria-hidden=\"true\">move your cursor</span>\n</div>",
    "css": "/* Enrichr Library · https://enrichr.dev */\n.ef-kti {\n  --ef-kti-k: 1; --ef-kti-size: 42;\n  --ef-kti-fg: #fbf8f1; --ef-kti-fg-rgb: 251, 248, 241; --ef-kti-muted: #9ca39b;\n  --ef-kti-bg: #0c1e18; --ef-kti-bg-rgb: 12, 30, 24; --ef-kti-top: #17281f;\n  --ef-kti-acc: #e6ff55; --ef-kti-acc-rgb: 230, 255, 85; --ef-kti-p: 0%;\n  --ef-kti-fs: min(calc(var(--ef-kti-size) * 1px), max(1.5rem, 9cqw));\n  position: relative; box-sizing: border-box; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem;\n  display: flex; flex-direction: column; container-type: inline-size; touch-action: pan-y;\n  color: var(--ef-kti-fg); font-family: 'Instrument Sans', system-ui, sans-serif;\n  background: radial-gradient(ellipse at 50% 120%, rgba(var(--ef-kti-acc-rgb), .1), transparent 60%), linear-gradient(180deg, var(--ef-kti-top), var(--ef-kti-bg));\n  border: 1px solid rgba(var(--ef-kti-acc-rgb), .2);\n  box-shadow: inset 0 1px 0 rgba(var(--ef-kti-fg-rgb), .05);\n}\n.ef-kti *, .ef-kti *::before, .ef-kti *::after { box-sizing: border-box; }\n.ef-kti:focus { outline: none; }\n.ef-kti:focus-visible { outline: 2px solid var(--ef-kti-acc); outline-offset: -4px; }\n\n/* specimen label */\n.ef-kti .ef-kti__head { position: relative; z-index: 2; display: flex; align-items: center; gap: 1rem; padding: 1.1rem 1.25rem 0; }\n.ef-kti[data-label=\"0\"] .ef-kti__head, .ef-kti[data-label=\"0\"] .ef-kti__hint { display: none; }\n.ef-kti .ef-kti__num {\n  flex-shrink: 0; width: 2.2rem; height: 2.2rem; border-radius: 50%; display: flex; align-items: center; justify-content: center;\n  font: 600 .78rem/1 'Fira Code', ui-monospace, monospace; color: var(--ef-kti-bg); background: var(--ef-kti-acc);\n  box-shadow: 0 0 0 1px rgba(var(--ef-kti-acc-rgb), .4), 0 4px 14px -4px rgba(var(--ef-kti-acc-rgb), .55);\n}\n.ef-kti .ef-kti__meta { display: block; min-width: 0; text-align: left; }\n.ef-kti .ef-kti__name { display: block; margin: 0 0 .15rem; font: 600 1.1rem/1.2 'Parkinsans', 'Space Grotesk', sans-serif; letter-spacing: .01em; color: var(--ef-kti-fg); opacity: 1; }\n.ef-kti .ef-kti__desc { display: block; margin: 0; font-size: .84rem; line-height: 1.45; color: var(--ef-kti-muted); opacity: 1; }\n.ef-kti.is-narrow .ef-kti__desc { display: none; }\n.ef-kti .ef-kti__hint {\n  position: absolute; z-index: 2; bottom: .9rem; right: .9rem; padding: .3rem .6rem; border-radius: 2px;\n  font: .68rem/1.2 'Fira Code', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--ef-kti-fg);\n  background: rgba(var(--ef-kti-bg-rgb), .65); border: 1px solid rgba(var(--ef-kti-acc-rgb), .3);\n  -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); pointer-events: none;\n}\n\n/* stage and type */\n.ef-kti .ef-kti__stage { position: relative; z-index: 1; flex: 1; display: flex; align-items: center; justify-content: center; text-align: center; padding: 2rem 1.5rem 3rem; min-height: 12rem; }\n.ef-kti .ef-kti__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n.ef-kti .ef-kti__demo {\n  display: inline-block; margin: 0; white-space: pre-wrap; color: var(--ef-kti-fg); opacity: 1;\n  font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-weight: 500; font-size: var(--ef-kti-fs); letter-spacing: .01em; line-height: 1.2;\n}\n.ef-kti .ef-kti__w { display: inline-block; white-space: nowrap; }\n.ef-kti .ef-kti__ch { display: inline-block; will-change: transform, opacity, filter; }\n\n/* 16 magnetic pull, 19 weight morph, 20 cursor distort */\n.ef-kti[data-fx=\"magnet\"] .ef-kti__ch, .ef-kti[data-fx=\"pressure\"] .ef-kti__ch, .ef-kti[data-fx=\"distort\"] .ef-kti__ch { transition: transform calc(.15s * var(--ef-kti-k)) ease-out; }\n\n/* 17 scroll scrub */\n.ef-kti[data-fx=\"scrub\"] .ef-kti__demo {\n  background: linear-gradient(90deg, var(--ef-kti-fg) var(--ef-kti-p), var(--ef-kti-muted) var(--ef-kti-p));\n  -webkit-background-clip: text; background-clip: text; color: transparent;\n}\n\n/* 18 hover scatter */\n.ef-kti[data-fx=\"scatter\"] .ef-kti__ch { transition: transform calc(.5s * var(--ef-kti-k)) cubic-bezier(.2, .9, .3, 1.1); }\n\n/* reduced motion: no letter movement; hover and focus only change the colour */\n.ef-kti.is-reduced .ef-kti__ch { transition: none !important; transform: none !important; }\n.ef-kti.is-reduced.is-hot .ef-kti__demo { color: var(--ef-kti-acc); }\n.ef-kti.is-reduced.is-hot[data-fx=\"scrub\"] .ef-kti__demo { color: transparent; }\n@media (prefers-reduced-motion: reduce) {\n  .ef-kti .ef-kti__ch { transition: none !important; }\n}",
    "js": "// Enrichr Library · https://enrichr.dev\n// Kinetic type interactive: the five studies from a kinetic type specimen book that answer the pointer or the scroll, one\n// at a time (pick it with effect). Magnetic pull: every letter moves toward the pointer by 35% of the distance times\n// min(1, 90 / distance), so close letters lean hard and far ones barely move, easing over 0.15 s. Scroll scrub: the word\n// fills from the left with the text colour in step with how far the element has travelled up the screen. Hover scatter:\n// on hover every letter jumps up to 40 px sideways, 30 px up or down and 40 degrees round, then springs back on leave over\n// 0.5 s on cubic-bezier(.2,.9,.3,1.1). Weight morph: each letter's weight is 900 minus 6 per pixel of horizontal distance\n// from the pointer, never below 300 (the font tops out at 800). Cursor distort: letters within 120 px of the pointer skew\n// up to 18 degrees away from it and grow up to 40%. Keyboard: focus the element and the arrow keys move a focus point\n// across the text (focus alone plays the scatter); Escape lets go. Reduced motion: letters never move; hover and focus only\n// change the colour, while the weight morph and the scroll fill (which do not move anything) still work.\n// mount(root, { effect (magnet | scrub | scatter | pressure | distort), text, speed (1 = original timing), size (px),\n//               color, accent, background, label (1 shows the specimen label and hint, 0 hides them) })\nfunction mount(root, o) {\n  var reduce = !!(window.matchMedia && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches);\n  var box = root.querySelector(\".ef-kti\");\n  var numEl = box.querySelector(\".ef-kti__num\"), nameEl = box.querySelector(\".ef-kti__name\");\n  var descEl = box.querySelector(\".ef-kti__desc\"), hintEl = box.querySelector(\".ef-kti__hint\");\n  var stage = box.querySelector(\".ef-kti__stage\");\n  var FX = {\n    magnet: [\"16\", \"Magnetic pull\", \"Letters lean toward your cursor as it nears.\", \"move your cursor\"],\n    scrub: [\"17\", \"Scroll scrub\", \"Colour fills the word in step with how far it has scrolled.\", \"scroll the page\"],\n    scatter: [\"18\", \"Hover scatter\", \"The word breaks apart at a touch, then finds its way back.\", \"hover\"],\n    pressure: [\"19\", \"Weight morph\", \"Letters gain weight the closer your cursor sits.\", \"move your cursor\"],\n    distort: [\"20\", \"Cursor distort\", \"A fisheye of skew and scale follows your pointer.\", \"move your cursor\"]\n  };\n  var opts = {}, fx = \"\", text = \"\", demo = null, spans = [], visible = true, io = null, ro = null, destroyed = false;\n  var vp = null; // keyboard focus point, as fractions of the stage box\n\n  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }\n  function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(String(h || \"\")); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }\n  function mix(a, b, t) { return \"rgb(\" + [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * t); }).join(\",\") + \")\"; }\n  function rand(a, b) { return Math.random() * (b - a) + a; }\n  function make(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }\n  function follows() { return fx === \"magnet\" || fx === \"pressure\" || fx === \"distort\"; }\n\n  function build() {\n    fx = FX[opts.effect] ? opts.effect : \"magnet\";\n    var f = FX[fx];\n    box.setAttribute(\"data-fx\", fx);\n    numEl.textContent = f[0]; nameEl.textContent = f[1]; descEl.textContent = f[2]; hintEl.textContent = f[3];\n    text = String(opts.text == null ? \"\" : opts.text).replace(/\\s+/g, \" \").trim() || \"BEND THE LINE\";\n    stage.textContent = \"\"; spans = []; vp = null;\n    box.classList.remove(\"is-hot\");\n    stage.appendChild(make(\"span\", \"ef-kti__sr\", text));\n    demo = make(\"span\", \"ef-kti__demo\"); demo.setAttribute(\"aria-hidden\", \"true\");\n    if (fx === \"scrub\") demo.textContent = text;\n    else {\n      var word = null;\n      Array.from(text).forEach(function (ch) {\n        if (/\\s/.test(ch)) { word = null; demo.appendChild(document.createTextNode(\" \")); return; }\n        if (!word) { word = make(\"span\", \"ef-kti__w\"); demo.appendChild(word); }\n        var s = make(\"span\", \"ef-kti__ch\", ch);\n        word.appendChild(s); spans.push(s);\n      });\n    }\n    stage.appendChild(demo);\n    scrub();\n  }\n\n  // the pointer maths, unchanged from the original; letters are measured where they are drawn right now\n  function at(mx, my) {\n    if (fx === \"magnet\") {\n      if (reduce) return;\n      spans.forEach(function (s) {\n        var r = s.getBoundingClientRect(), dx = mx - (r.left + r.width / 2), dy = my - (r.top + r.height / 2);\n        var dist = Math.max(30, Math.sqrt(dx * dx + dy * dy)), pull = Math.min(1, 90 / dist);\n        s.style.transform = \"translate(\" + dx * pull * 0.35 + \"px, \" + dy * pull * 0.35 + \"px)\";\n      });\n    } else if (fx === \"pressure\") {\n      spans.forEach(function (s) {\n        var r = s.getBoundingClientRect(), dist = Math.abs(mx - (r.left + r.width / 2));\n        s.style.fontVariationSettings = '\"wght\" ' + Math.max(300, 900 - dist * 6);\n      });\n    } else if (fx === \"distort\") {\n      if (reduce) return;\n      spans.forEach(function (s) {\n        var r = s.getBoundingClientRect(), dist = mx - (r.left + r.width / 2);\n        var prox = Math.max(0, 1 - Math.abs(dist) / 120), skew = (dist > 0 ? -1 : 1) * prox * 18, scale = 1 + prox * 0.4;\n        s.style.transform = \"skewX(\" + skew + \"deg) scale(\" + scale + \")\";\n      });\n    }\n  }\n  function scatter() {\n    if (fx !== \"scatter\" || reduce) return;\n    spans.forEach(function (s) { s.style.transform = \"translate(\" + rand(-40, 40) + \"px, \" + rand(-30, 30) + \"px) rotate(\" + rand(-40, 40) + \"deg)\"; });\n  }\n  function release() {\n    box.classList.remove(\"is-hot\");\n    spans.forEach(function (s) { s.style.transform = \"\"; if (fx === \"pressure\") s.style.fontVariationSettings = '\"wght\" 400'; });\n  }\n  function scrub() {\n    if (fx !== \"scrub\" || !visible) return;\n    var r = box.getBoundingClientRect(), vh = window.innerHeight || document.documentElement.clientHeight;\n    var p = 1 - (r.top + r.height / 2) / (vh + r.height / 2);\n    p = Math.max(0, Math.min(1, p));\n    box.style.setProperty(\"--ef-kti-p\", (p * 100).toFixed(1) + \"%\");\n  }\n  function virtual() {\n    var r = stage.getBoundingClientRect();\n    at(r.left + r.width * vp.x, r.top + r.height * vp.y);\n  }\n\n  function onMove(e) { if (follows()) at(e.clientX, e.clientY); }\n  function onEnter() { box.classList.add(\"is-hot\"); scatter(); }\n  function onLeave() { if (!vp) release(); }\n  function onFocus() {\n    if (!box.matches(\":focus-visible\")) return;\n    box.classList.add(\"is-hot\");\n    if (fx === \"scatter\") scatter();\n    else if (follows()) {\n      var r = stage.getBoundingClientRect(), d = demo.getBoundingClientRect();\n      vp = { x: (d.left + d.width * 0.2 - r.left) / r.width, y: (d.top + d.height / 2 - r.top) / r.height };\n      virtual();\n    }\n  }\n  function onBlur() { vp = null; release(); }\n  function onKey(e) {\n    if (e.key === \"Escape\") { vp = null; release(); return; }\n    if (fx === \"scatter\" && (e.key === \"Enter\" || e.key === \" \")) { e.preventDefault(); box.classList.add(\"is-hot\"); scatter(); return; }\n    if (!vp || !follows()) return;\n    var step = { ArrowLeft: [-0.04, 0], ArrowRight: [0.04, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];\n    if (!step || (step[1] && fx !== \"magnet\")) return;\n    e.preventDefault();\n    vp.x = Math.max(0, Math.min(1, vp.x + step[0])); vp.y = Math.max(0, Math.min(1, vp.y + step[1]));\n    virtual();\n  }\n\n  function paint() {\n    var fg = rgb(opts.color, \"fbf8f1\"), bg = rgb(opts.background, \"0c1e18\"), ac = rgb(opts.accent, \"e6ff55\"), s = box.style;\n    s.setProperty(\"--ef-kti-fg\", \"rgb(\" + fg + \")\"); s.setProperty(\"--ef-kti-fg-rgb\", fg.join(\",\"));\n    s.setProperty(\"--ef-kti-bg\", \"rgb(\" + bg + \")\"); s.setProperty(\"--ef-kti-bg-rgb\", bg.join(\",\"));\n    s.setProperty(\"--ef-kti-top\", mix(bg, fg, 0.045)); s.setProperty(\"--ef-kti-muted\", mix(bg, fg, 0.62));\n    s.setProperty(\"--ef-kti-acc\", \"rgb(\" + ac + \")\"); s.setProperty(\"--ef-kti-acc-rgb\", ac.join(\",\"));\n    s.setProperty(\"--ef-kti-k\", String(1 / opts.speed));\n    s.setProperty(\"--ef-kti-size\", String(opts.size));\n    box.setAttribute(\"data-label\", opts.label ? \"1\" : \"0\");\n    box.classList.toggle(\"is-reduced\", reduce);\n  }\n  function update(partial) {\n    var prev = opts;\n    opts = Object.assign({ effect: \"magnet\", text: \"BEND THE LINE\", speed: 1, size: 42, color: \"#fbf8f1\", accent: \"#e6ff55\", background: \"#0c1e18\", label: 1 }, opts, partial || {});\n    opts.speed = Math.min(5, Math.max(0.1, num(opts.speed, 1)));\n    opts.size = Math.max(10, num(opts.size, 42));\n    opts.label = !(opts.label === 0 || opts.label === \"0\" || opts.label === false || opts.label === \"off\");\n    paint();\n    if (destroyed) return;\n    if (!fx || prev.effect !== opts.effect || prev.text !== opts.text) build();\n  }\n\n  update(o);\n  box.addEventListener(\"pointermove\", onMove);\n  box.addEventListener(\"pointerenter\", onEnter);\n  box.addEventListener(\"pointerleave\", onLeave);\n  box.addEventListener(\"focus\", onFocus);\n  box.addEventListener(\"blur\", onBlur);\n  box.addEventListener(\"keydown\", onKey);\n  window.addEventListener(\"scroll\", scrub, { passive: true });\n  window.addEventListener(\"resize\", scrub);\n  if (\"IntersectionObserver\" in window) {\n    io = new IntersectionObserver(function (es) { visible = es[es.length - 1].isIntersecting; scrub(); });\n    io.observe(box);\n  }\n  if (\"ResizeObserver\" in window) {\n    ro = new ResizeObserver(function () { if (destroyed) return; box.classList.toggle(\"is-narrow\", box.clientWidth < 520); scrub(); });\n    ro.observe(box);\n  }\n  return {\n    update: update,\n    destroy: function () {\n      destroyed = true;\n      box.removeEventListener(\"pointermove\", onMove);\n      box.removeEventListener(\"pointerenter\", onEnter);\n      box.removeEventListener(\"pointerleave\", onLeave);\n      box.removeEventListener(\"focus\", onFocus);\n      box.removeEventListener(\"blur\", onBlur);\n      box.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"scroll\", scrub);\n      window.removeEventListener(\"resize\", scrub);\n      if (io) io.disconnect();\n      if (ro) ro.disconnect();\n    }\n  };\n}"
  }
}