{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"focus-paragraph","type":"registry:component","title":"Focus paragraph","description":"Brings the sentence you're reading forward and lets the rest step back.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/focus-paragraph.tsx","type":"registry:component","content":"\"use client\";\n\nimport { Fragment, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\nexport function FocusParagraph({\n  sentences,\n  active: activeProp,\n  className,\n}: {\n  sentences: string[];\n  /**\n   * Which sentence is lit, from outside (null lights none). Leave undefined\n   * to follow the reader's pointer and keyboard.\n   */\n  active?: number | null;\n  className?: string;\n}) {\n  const [hovered, setActive] = useState<number | null>(null);\n  const active = activeProp === undefined ? hovered : activeProp;\n  // The sentence holding keyboard focus, so a mouse leaving the paragraph\n  // hands focus back to it instead of clearing everything.\n  const focused = useRef<number | null>(null);\n\n  return (\n    <p\n      className={cn(\"max-w-[440px] text-base leading-7 text-pretty text-foreground\", className)}\n      onPointerLeave={(e) => {\n        if (e.pointerType === \"touch\") return;\n        setActive(focused.current);\n      }}\n      onBlur={(e) => {\n        if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n        focused.current = null;\n        setActive(null);\n      }}\n    >\n      {sentences.map((sentence, i) => (\n        <Fragment key={i}>\n          {/* Gaps between sentences are bare text, not spans, so crossing one\n              fires no enter event and the last sentence simply stays lit. */}\n          {i > 0 && \" \"}\n          <span\n            // Each sentence is a stop for keyboard readers.\n            tabIndex={0}\n            data-state={active === null ? \"idle\" : active === i ? \"focus\" : \"dim\"}\n            onPointerEnter={(e) => {\n              if (e.pointerType !== \"touch\") setActive(i);\n            }}\n            onPointerUp={(e) => {\n              // Touch has no hover, so a tap toggles the sentence instead.\n              if (e.pointerType === \"touch\") setActive((a) => (a === i ? null : i));\n            }}\n            onFocus={(e) => {\n              // A mouse click focuses the span too; only a keyboard visit\n              // should pin the focus, or leaving would no longer restore.\n              if (!e.currentTarget.matches(\":focus-visible\")) return;\n              focused.current = i;\n              setActive(i);\n            }}\n            className={cn(\n              \"rounded-[3px] box-decoration-clone outline-hidden\",\n              \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground/40\",\n              \"transition-[opacity,filter] ease-[cubic-bezier(0.23,1,0.32,1)]\",\n              // Coming into focus is quick, stepping back is softer: the eye\n              // should land on the sentence before the rest has settled.\n              \"data-[state=dim]:opacity-40 data-[state=dim]:blur-[0.6px] data-[state=dim]:duration-300\",\n              \"data-[state=focus]:duration-200 data-[state=idle]:duration-300\",\n              \"motion-reduce:data-[state=dim]:filter-none\",\n            )}\n          >\n            {sentence}\n          </span>\n        </Fragment>\n      ))}\n    </p>\n  );\n}\n\nconst SENTENCES = [\n  \"Good interfaces rarely ask for attention.\",\n  \"They wait at the edge of the page until your hand arrives, then answer with the smallest possible gesture.\",\n  \"A button settles when pressed.\",\n  \"A sentence steps forward while you read it.\",\n  \"Everything else politely steps back.\",\n];\n\nexport default function FocusParagraphDemo() {\n  const play = usePreviewPlay();\n  const [reading, setReading] = useState<number | null>(null);\n\n  // Index preview: the eye reads down the paragraph, lingering on each\n  // sentence for about as long as it takes to read, then lets go.\n  useEffect(() => {\n    if (!play) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const show = () => {\n      let at = 250;\n      SENTENCES.forEach((sentence, i) => {\n        timers.push(setTimeout(() => setReading(i), at));\n        // A quick skim, not real reading speed, so the show stays short.\n        at += 450 + sentence.length * 9;\n      });\n      timers.push(setTimeout(() => setReading(null), at));\n      timers.push(setTimeout(show, at + 1300));\n    };\n    show();\n    return () => {\n      timers.forEach(clearTimeout);\n      setReading(null);\n    };\n  }, [play]);\n\n  return <FocusParagraph sentences={SENTENCES} active={play === null ? undefined : play ? reading : null} />;\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"categories":["text"],"docs":"From ui lab: https://lab.xevrion.dev/lab/focus-paragraph"}