{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"focus","type":"registry:ui","title":"Focus","description":"Text component that cycles focus through words with blur and corner frame","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["text"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/focus/Focus.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport \"./focus.css\";\n\n/**\n * Focus - Sentence with a roving focus frame\n *\n * Splits a sentence into words, blurs all but the current one, and moves a\n * cornered frame over the word in focus. Cycles automatically on an interval,\n * or follows the hovered word in manual mode.\n */\nexport interface FocusProps {\n\t/** Sentence to split into words */\n\tsentence?: string;\n\t/** Follow the hovered word instead of auto-cycling */\n\tmanualMode?: boolean;\n\t/** Blur applied to out-of-focus words, in px */\n\tblurAmount?: number;\n\t/** Color of the focus frame corners */\n\tborderColor?: string;\n\t/** Focus transition duration in seconds */\n\tanimationDuration?: number;\n\t/** Pause between auto-cycle steps in seconds */\n\tpauseBetweenAnimations?: number;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nexport function Focus({\n\tsentence = \"Fancy Focus\",\n\tmanualMode = false,\n\tblurAmount = 5,\n\tborderColor = \"green\",\n\tanimationDuration = 0.5,\n\tpauseBetweenAnimations = 1,\n\tclassName = \"\",\n}: FocusProps) {\n\tconst words = sentence.split(\" \");\n\tconst containerRef = useRef<HTMLDivElement | null>(null);\n\tconst wordElements = useRef<(HTMLSpanElement | null)[]>([]);\n\tconst [currentIndex, setCurrentIndex] = useState(0);\n\tconst [focusRect, setFocusRect] = useState({ x: 0, y: 0, width: 0, height: 0 });\n\n\t// The interval callback reads the live word count, as the Svelte closure\n\t// does over its derived value, without restarting the interval.\n\tconst wordCount = useLiveRef(words.length);\n\n\tfunction handleMouseEnter(index: number) {\n\t\tif (manualMode) {\n\t\t\tsetCurrentIndex(index);\n\t\t}\n\t}\n\n\tfunction handleMouseLeave() {\n\t\tif (manualMode) {\n\t\t\tsetCurrentIndex(0);\n\t\t}\n\t}\n\n\t// Pre-paint, the counterpart of `$effect` + `tick()`: measures the current word\n\t// once the DOM reflects `currentIndex`, before the browser paints. Covers mount\n\t// too. A passive effect would paint the zero-size frame at the container origin\n\t// first, then transition it into place — a visible slide-and-grow from (0,0).\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst wordEl = wordElements.current[currentIndex];\n\t\tconst container = containerRef.current;\n\t\tif (!wordEl || !container) return;\n\t\tconst parentRect = container.getBoundingClientRect();\n\t\tconst wordRect = wordEl.getBoundingClientRect();\n\t\tsetFocusRect({\n\t\t\tx: wordRect.left - parentRect.left,\n\t\t\ty: wordRect.top - parentRect.top,\n\t\t\twidth: wordRect.width,\n\t\t\theight: wordRect.height,\n\t\t});\n\t}, [currentIndex]);\n\n\t// Mount-only, as the Svelte side starts its interval in onMount: the timing\n\t// props are captured once and a later change does not restart the cycle. A\n\t// plain ref holds them — it is never written after the first render, so the\n\t// mount effect reads the same values a Svelte `onMount` closure would.\n\tconst startupRef = useRef({ manualMode, animationDuration, pauseBetweenAnimations });\n\tuseEffect(() => {\n\t\tconst { manualMode, animationDuration, pauseBetweenAnimations } = startupRef.current;\n\t\tif (manualMode) return;\n\t\tconst intervalMs = animationDuration * 1000 + pauseBetweenAnimations * 1000;\n\t\tconst id = setInterval(() => {\n\t\t\tsetCurrentIndex((index) => (index + 1) % wordCount.current);\n\t\t}, intervalMs);\n\t\treturn () => clearInterval(id);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t<div ref={containerRef} className={cn(\"focus-container\", className)}>\n\t\t\t{words.map((word, index) => (\n\t\t\t\t<span\n\t\t\t\t\tkey={index}\n\t\t\t\t\tref={(node) => {\n\t\t\t\t\t\twordElements.current[index] = node;\n\t\t\t\t\t}}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"focus-word\",\n\t\t\t\t\t\tmanualMode && \"manual\",\n\t\t\t\t\t\tindex === currentIndex && !manualMode && \"active\"\n\t\t\t\t\t)}\n\t\t\t\t\tstyle={\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tfilter: index === currentIndex ? \"blur(0px)\" : `blur(${blurAmount}px)`,\n\t\t\t\t\t\t\t\"--focus-duration\": `${animationDuration}s`,\n\t\t\t\t\t\t\t\"--border-color\": borderColor,\n\t\t\t\t\t\t} as CSSProperties\n\t\t\t\t\t}\n\t\t\t\t\tonMouseEnter={() => handleMouseEnter(index)}\n\t\t\t\t\tonMouseLeave={handleMouseLeave}\n\t\t\t\t>\n\t\t\t\t\t{word}\n\t\t\t\t</span>\n\t\t\t))}\n\n\t\t\t<div\n\t\t\t\tclassName=\"focus-frame\"\n\t\t\t\tstyle={\n\t\t\t\t\t{\n\t\t\t\t\t\ttransform: `translate(${focusRect.x}px, ${focusRect.y}px)`,\n\t\t\t\t\t\twidth: `${focusRect.width}px`,\n\t\t\t\t\t\theight: `${focusRect.height}px`,\n\t\t\t\t\t\topacity: currentIndex >= 0 ? 1 : 0,\n\t\t\t\t\t\t\"--focus-duration\": `${animationDuration}s`,\n\t\t\t\t\t\t\"--border-color\": borderColor,\n\t\t\t\t\t} as CSSProperties\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<span className=\"corner top-left\"></span>\n\t\t\t\t<span className=\"corner top-right\"></span>\n\t\t\t\t<span className=\"corner bottom-left\"></span>\n\t\t\t\t<span className=\"corner bottom-right\"></span>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/focus/Focus.tsx"},{"path":"react/src/components/focus/focus.css","content":"/* Rules below .focus-container are anchored on the root class (port-added\n   anchoring — the Svelte compiler scoped them; as plain CSS they would leak\n   into any consumer element named .focus-word or .corner). See PORTING.md,\n   styling rule 2. */\n.focus-container {\n\tposition: relative;\n\tdisplay: flex;\n\tgap: 1em;\n\tjustify-content: center;\n\talign-items: center;\n\tflex-wrap: wrap;\n}\n\n.focus-container .focus-word {\n\tposition: relative;\n\tfont-size: 3rem;\n\tfont-weight: 900;\n\tcursor: pointer;\n\ttransition: color 0.3s ease;\n}\n\n.focus-container .focus-word.active {\n\tfilter: blur(0);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.focus-container .focus-word {\n\t\ttransition:\n\t\t\tfilter var(--focus-duration, 0.3s) ease,\n\t\t\tcolor 0.3s ease;\n\t}\n\n\t.focus-container .focus-frame {\n\t\ttransition: all var(--focus-duration, 0.5s) ease;\n\t}\n}\n\n.focus-container .focus-frame {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\tpointer-events: none;\n\tbox-sizing: content-box;\n\tborder: none;\n}\n\n.focus-container .corner {\n\tposition: absolute;\n\twidth: 1rem;\n\theight: 1rem;\n\tborder: 3px solid var(--border-color, #fff);\n\tfilter: drop-shadow(0px 0px 4px var(--border-color, #fff));\n\tborder-radius: 3px;\n\ttransition: none;\n}\n\n.focus-container .top-left {\n\ttop: -10px;\n\tleft: -10px;\n\tborder-right: none;\n\tborder-bottom: none;\n}\n\n.focus-container .top-right {\n\ttop: -10px;\n\tright: -10px;\n\tborder-left: none;\n\tborder-bottom: none;\n}\n\n.focus-container .bottom-left {\n\tbottom: -10px;\n\tleft: -10px;\n\tborder-right: none;\n\tborder-top: none;\n}\n\n.focus-container .bottom-right {\n\tbottom: -10px;\n\tright: -10px;\n\tborder-left: none;\n\tborder-top: none;\n}\n","type":"registry:ui","target":"@ui/fancy/components/focus/focus.css"},{"path":"react/src/components/focus/index.ts","content":"\"use client\";\n\nexport { Focus } from \"./Focus\";\nexport type { FocusProps } from \"./Focus\";\n","type":"registry:ui","target":"@ui/fancy/components/focus/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/focus?framework=react"}}