{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ds-scroll-to-top",
  "title": "Scroll to Top",
  "description": "Floating button with scroll-progress ring that reveals past a configurable scroll threshold. Tooltip, i18n support, and multiple positions.",
  "dependencies": [
    "lucide-react",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "@lema-ds/tooltip",
    "@lema-ds/skeleton",
    "@lema-ds/ui-i18n",
    "@lema-ds/ds-locale-provider"
  ],
  "files": [
    {
      "path": "components/ds/ds-scroll-to-top.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUp } from \"lucide-react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport type { HTMLAttributes } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { UI_I18N, type UILocale } from \"@/lib/ui-i18n\"\nimport { useUILocale } from \"@/components/ui/ds-locale-provider\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n  TooltipProvider,\n} from \"@/components/ui/tooltip\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\n\n// ── Types ──\n\nexport interface ScrollToTopProps\n  extends\n    Omit<HTMLAttributes<HTMLDivElement>, \"children\">,\n    VariantProps<typeof scrollToTopVariants> {\n  threshold?: number\n  showProgress?: boolean\n  locale?: UILocale\n  loading?: boolean\n  variant?: \"outline\" | \"fill\"\n}\n\nexport type ScrollToTopPosition = NonNullable<\n  VariantProps<typeof scrollToTopVariants>[\"position\"]\n>\n\nexport type ScrollToTopVariant = NonNullable<\n  VariantProps<typeof buttonVariants>[\"variant\"]\n>\n\n// ── Variants ──\n\nconst scrollToTopVariants = cva(\n  \"fixed z-50 transition-all duration-400 ease-out motion-safe:transition-all motion-safe:duration-400 motion-safe:ease-out\",\n  {\n    variants: {\n      position: {\n        \"bottom-right\":\n          \"right-4 bottom-4 sm:right-6 sm:bottom-6 lg:right-8 lg:bottom-8\",\n        \"bottom-left\":\n          \"bottom-4 left-4 sm:bottom-6 sm:left-6 lg:bottom-8 lg:left-8\",\n        \"top-right\": \"top-4 right-4 sm:top-6 sm:right-6 lg:top-8 lg:right-8\",\n        \"top-left\": \"top-4 left-4 sm:top-6 sm:left-6 lg:top-8 lg:left-8\",\n      },\n    },\n    defaultVariants: { position: \"bottom-right\" },\n  }\n)\n\nconst buttonVariants = cva(\n  [\n    \"relative flex items-center justify-center rounded-full\",\n    \"transition-all duration-200 hover:scale-110 hover:shadow-xl active:scale-95\",\n    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none\",\n  ],\n  {\n    variants: {\n      variant: {\n        outline: \"bg-secondary text-secondary-foreground shadow-lg\",\n        fill: \"bg-primary text-primary-foreground shadow-lg shadow-primary/20\",\n      },\n    },\n    defaultVariants: { variant: \"outline\" },\n  }\n)\n\n// ── Helpers – scroll state hook ──\n\nfunction useScrollState(threshold: number) {\n  const [state, setState] = React.useState({ progress: 0, visible: false })\n\n  React.useEffect(() => {\n    let ticking = false\n\n    const onScroll = () => {\n      if (ticking) return\n      requestAnimationFrame(() => {\n        const scrollY = globalThis.scrollY\n        const maxScroll = Math.max(\n          document.documentElement.scrollHeight -\n            document.documentElement.clientHeight,\n          0\n        )\n        const progress = maxScroll > 0 ? Math.min(scrollY / maxScroll, 1) : 0\n        setState({ progress, visible: scrollY > threshold })\n        ticking = false\n      })\n      ticking = true\n    }\n\n    globalThis.addEventListener(\"scroll\", onScroll, { passive: true })\n    onScroll()\n    return () => globalThis.removeEventListener(\"scroll\", onScroll)\n  }, [threshold])\n\n  return state\n}\n\n// ── Helpers – progress ring SVG ──\n\nfunction ProgressRing({\n  progress,\n  size,\n  variant = \"outline\",\n}: {\n  progress: number\n  size: number\n  variant?: ScrollToTopVariant\n}) {\n  const strokeWidth = 2.5\n  const radius = (size - strokeWidth * 2) / 2\n  const circumference = 2 * Math.PI * radius\n  const offset = circumference * (1 - progress)\n\n  return (\n    <svg\n      className=\"absolute inset-0 -rotate-90\"\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      aria-hidden=\"true\"\n      fill=\"none\"\n    >\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        stroke=\"currentColor\"\n        strokeWidth={strokeWidth}\n        className=\"text-muted/20\"\n      />\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        stroke=\"currentColor\"\n        strokeWidth={strokeWidth}\n        strokeLinecap=\"round\"\n        className={cn(\n          \"transition-[stroke-dashoffset] duration-150 ease-linear motion-reduce:transition-none\",\n          variant === \"outline\" ? \"text-primary\" : \"text-primary-foreground/70\"\n        )}\n        strokeDasharray={circumference}\n        strokeDashoffset={offset}\n      />\n    </svg>\n  )\n}\n\n// ── Helpers – size map ──\n\nconst sizeMap = {\n  sm: { container: \"size-9\", icon: \"size-3.5\", ring: 36 },\n  md: { container: \"size-11\", icon: \"size-4\", ring: 44 },\n  lg: { container: \"size-12\", icon: \"size-5\", ring: 48 },\n} as const\n\n// ── Component ──\n\nexport const ScrollToTop = React.forwardRef<HTMLDivElement, ScrollToTopProps>(\n  (\n    {\n      threshold = 400,\n      showProgress = true,\n      locale: localeProp,\n      loading = false,\n      variant = \"outline\",\n      position = \"bottom-right\",\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const locale = useUILocale(localeProp)\n    const { progress, visible } = useScrollState(threshold)\n    const i18n = UI_I18N[locale].scrollToTop\n\n    const mounted = React.useSyncExternalStore(\n      React.useCallback((cb: () => void) => {\n        cb()\n        return () => {}\n      }, []),\n      () => true,\n      () => false\n    )\n\n    const handleClick = () => {\n      globalThis.scrollTo({ top: 0, behavior: \"smooth\" })\n    }\n\n    if (!mounted) return null\n\n    if (loading) {\n      return (\n        <div\n          ref={ref}\n          className={cn(scrollToTopVariants({ position }), className)}\n          {...props}\n        >\n          <Skeleton className=\"size-11 rounded-full\" />\n        </div>\n      )\n    }\n\n    return (\n      <div\n        ref={ref}\n        role=\"complementary\"\n        aria-label={i18n.label}\n        className={cn(\n          scrollToTopVariants({ position }),\n          visible\n            ? \"translate-y-0 opacity-100\"\n            : \"pointer-events-none translate-y-3 opacity-0\",\n          className\n        )}\n        {...props}\n      >\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <button\n                type=\"button\"\n                onClick={handleClick}\n                aria-label={i18n.tooltip}\n                className={cn(\n                  buttonVariants({ variant }),\n                  sizeMap.md.container\n                )}\n              >\n                {showProgress && (\n                  <ProgressRing\n                    progress={progress}\n                    size={sizeMap.md.ring}\n                    variant={variant}\n                  />\n                )}\n                <ArrowUp\n                  className={cn(\"relative\", sizeMap.md.icon)}\n                  aria-hidden=\"true\"\n                />\n              </button>\n            </TooltipTrigger>\n            <TooltipContent side=\"left\">\n              <p>{i18n.tooltip}</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </div>\n    )\n  }\n)\n\nScrollToTop.displayName = \"ScrollToTop\"\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
