{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"feature-section","type":"registry:block","title":"Feature Section","description":"Marketing features section with a vertical tab list beside a visual panel that cross-fades and slides in the direction of travel to each feature's visual, autoplay that fills a progress rail on the active row and advances, pausing on hover or focus, with a pause button and keyboard tabs.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/feature-section.tsx","type":"registry:component","target":"@components/motion/feature-section.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/feature-section\n\nimport { Pause, Play } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FeatureSectionItem {\n  id: string;\n  title: string;\n  description: ReactNode;\n  icon?: ReactNode;\n  /** What the visual panel shows while this feature is active. */\n  visual: ReactNode;\n}\n\nexport interface FeatureSectionProps {\n  features: FeatureSectionItem[];\n  eyebrow?: ReactNode;\n  heading?: ReactNode;\n  description?: ReactNode;\n  /** Controlled active feature id. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  /** Advance through features on a timer. Default true. */\n  autoplay?: boolean;\n  /** Milliseconds per feature. Default 6000. */\n  interval?: number;\n  /** Visual panel side at wide sizes. Default \"right\". */\n  visualPosition?: \"left\" | \"right\";\n  /** Classes for the visual panel (set its height here). Default `h-72`. */\n  visualClassName?: string;\n  className?: string;\n}\n\n/**\n * Marketing features block: a vertical tab list of features beside a visual\n * panel that cross-fades and slides (in the direction of travel) to each\n * feature's visual. With `autoplay`, the active row fills a progress rail and\n * advances when it completes; hovering or focusing the block pauses it, and a\n * pause button stops it for good. Only runs while on screen.\n */\nexport function FeatureSection({\n  features,\n  eyebrow,\n  heading,\n  description,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  autoplay = true,\n  interval = 6000,\n  visualPosition = \"right\",\n  visualClassName,\n  className,\n}: FeatureSectionProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const rootRef = useRef<HTMLElement>(null);\n  const inView = useInView(rootRef, { amount: 0.3 });\n  const [internal, setInternal] = useState(defaultValue ?? features[0]?.id ?? \"\");\n  const controlled = valueProp !== undefined;\n  const active = controlled ? valueProp : internal;\n  const [dir, setDir] = useState<1 | -1>(1);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [stopped, setStopped] = useState(false);\n  const progress = useMotionValue(0);\n  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const activeIndex = Math.max(\n    0,\n    features.findIndex((f) => f.id === active),\n  );\n\n  const select = useCallback(\n    (index: number) => {\n      const target = features[(index + features.length) % features.length];\n      if (!target) return;\n      setDir(index >= activeIndex ? 1 : -1);\n      progress.set(0);\n      if (!controlled) setInternal(target.id);\n      onValueChange?.(target.id);\n    },\n    [features, activeIndex, controlled, onValueChange, progress],\n  );\n\n  const running = autoplay && !stopped && !hovered && !focused && inView && features.length > 1;\n\n  // A frame loop owns the progress value so pausing holds the exact position.\n  const selectRef = useRef(select);\n  selectRef.current = select;\n  useEffect(() => {\n    if (!running) return;\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const next = progress.get() + (now - last) / interval;\n      last = now;\n      if (next >= 1) {\n        selectRef.current(activeIndex + 1);\n      } else progress.set(next);\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [running, interval, progress, activeIndex]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    const map: Record<string, number> = {\n      ArrowDown: index + 1,\n      ArrowRight: index + 1,\n      ArrowUp: index - 1,\n      ArrowLeft: index - 1,\n      Home: 0,\n      End: features.length - 1,\n    };\n    if (!(event.key in map)) return;\n    event.preventDefault();\n    const next = (map[event.key] + features.length) % features.length;\n    select(next);\n    tabRefs.current[next]?.focus();\n  };\n\n  const current = features[activeIndex];\n  const panelId = `${baseId}-panel`;\n  const slide = {\n    enter: (d: number) =>\n      reduce ? { opacity: 0 } : { opacity: 0, y: d * 28, scale: 0.98 },\n    center: { opacity: 1, y: 0, scale: 1 },\n    exit: (d: number) =>\n      reduce\n        ? { opacity: 0 }\n        : { opacity: 0, y: d * -28, scale: 0.98, transition: { duration: 0.2, ease: EASE_OUT } },\n  };\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: hover/focus only pause autoplay; the controls inside are real tabs and buttons.\n    <section\n      ref={rootRef}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      onFocus={() => setFocused(true)}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);\n      }}\n      className={cn(\"@container w-full\", className)}\n    >\n      {eyebrow || heading || description ? (\n        <header className=\"mb-6 max-w-xl\">\n          {eyebrow ? (\n            <p className=\"text-xs font-medium uppercase tracking-wider text-muted-foreground\">\n              {eyebrow}\n            </p>\n          ) : null}\n          {heading ? (\n            <h2 className=\"mt-1 text-2xl font-semibold tracking-tight @xl:text-3xl\">{heading}</h2>\n          ) : null}\n          {description ? <p className=\"mt-2 text-sm text-muted-foreground\">{description}</p> : null}\n        </header>\n      ) : null}\n\n      <div\n        className={cn(\n          \"grid gap-4 @xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] @xl:items-center\",\n          visualPosition === \"left\" && \"@xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]\",\n        )}\n      >\n        <div className={cn(\"flex flex-col\", visualPosition === \"left\" && \"@xl:order-2\")}>\n          <div role=\"tablist\" aria-orientation=\"vertical\" aria-label=\"Features\" className=\"flex flex-col gap-1\">\n            {features.map((feature, i) => {\n              const isActive = i === activeIndex;\n              return (\n                <button\n                  key={feature.id}\n                  ref={(node) => {\n                    tabRefs.current[i] = node;\n                  }}\n                  type=\"button\"\n                  role=\"tab\"\n                  id={`${baseId}-tab-${feature.id}`}\n                  aria-selected={isActive}\n                  aria-controls={panelId}\n                  tabIndex={isActive ? 0 : -1}\n                  onClick={() => select(i)}\n                  onKeyDown={(e) => onKeyDown(e, i)}\n                  className={cn(\n                    \"relative overflow-hidden rounded-xl py-3 pr-3 pl-5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary\",\n                    isActive ? \"bg-muted\" : \"hover:bg-muted/50\",\n                  )}\n                >\n                  {/* Progress rail: a track with a fill scaled from the top. */}\n                  <span aria-hidden className=\"absolute top-3 bottom-3 left-2 w-0.5 overflow-hidden rounded-full bg-foreground/10\">\n                    {isActive ? (\n                      <motion.span\n                        style={{ scaleY: autoplay ? progress : 1 }}\n                        className=\"absolute inset-0 origin-top rounded-full bg-primary\"\n                      />\n                    ) : null}\n                  </span>\n                  <span className=\"flex items-center gap-2.5\">\n                    {feature.icon ? (\n                      <span\n                        className={cn(\n                          \"grid size-7 shrink-0 place-items-center rounded-lg transition-colors [&_svg]:size-3.5\",\n                          isActive ? \"bg-primary text-primary-foreground\" : \"bg-muted text-muted-foreground\",\n                        )}\n                      >\n                        {feature.icon}\n                      </span>\n                    ) : null}\n                    <span\n                      className={cn(\n                        \"text-sm font-medium transition-colors\",\n                        isActive ? \"text-foreground\" : \"text-muted-foreground\",\n                      )}\n                    >\n                      {feature.title}\n                    </span>\n                  </span>\n                  <AnimatePresence initial={false}>\n                    {isActive ? (\n                      // Height animated on purpose: the open row has to push the\n                      // rows below it down, which a transform can't do.\n                      <motion.span\n                        key=\"desc\"\n                        initial={{ height: 0, opacity: 0 }}\n                        animate={{ height: \"auto\", opacity: 1 }}\n                        exit={{ height: 0, opacity: 0 }}\n                        transition={reduce ? { duration: 0 } : { duration: 0.28, ease: EASE_OUT }}\n                        className=\"block overflow-hidden\"\n                      >\n                        <span\n                          className={cn(\n                            \"block pt-1.5 text-xs leading-relaxed text-muted-foreground\",\n                            feature.icon && \"pl-9.5\",\n                          )}\n                        >\n                          {feature.description}\n                        </span>\n                      </motion.span>\n                    ) : null}\n                  </AnimatePresence>\n                </button>\n              );\n            })}\n          </div>\n          {autoplay && features.length > 1 ? (\n            <button\n              type=\"button\"\n              onClick={() => setStopped(!stopped)}\n              aria-label={stopped ? \"Resume autoplay\" : \"Pause autoplay\"}\n              className=\"mt-2 inline-flex h-7 items-center gap-1.5 self-start rounded-full px-2.5 text-[11px] text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary\"\n            >\n              {stopped ? <Play aria-hidden className=\"size-3\" /> : <Pause aria-hidden className=\"size-3\" />}\n              {stopped ? \"Play\" : \"Pause\"}\n            </button>\n          ) : null}\n        </div>\n\n        <div\n          id={panelId}\n          role=\"tabpanel\"\n          aria-labelledby={current ? `${baseId}-tab-${current.id}` : undefined}\n          className={cn(\n            \"relative overflow-hidden rounded-2xl border border-border bg-muted/40\",\n            visualClassName ?? \"h-72\",\n            visualPosition === \"left\" && \"@xl:order-1\",\n          )}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n            {current ? (\n              <motion.div\n                key={current.id}\n                custom={dir}\n                variants={slide}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={{ duration: 0.35, ease: EASE_OUT }}\n                className=\"absolute inset-0\"\n              >\n                {current.visual}\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </div>\n    </section>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}