{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "back-top",
  "title": "Back Top",
  "type": "registry:ui",
  "description": "Scroll-to-top floating button. Appears after the target container scrolls past a threshold and smooth-scrolls back on click. Supports custom target container, visibility threshold, scroll behavior, custom icon slot, four edge anchors, size variants, and edge offset.",
  "categories": [
    "navigation",
    "utility"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/back-top/back-top.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { ArrowUp } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { backTopVariants, type BackTopVariants } from './back-top.variants'\n\ntype Size = NonNullable<BackTopVariants['size']>\ntype Position = NonNullable<BackTopVariants['position']>\n\n/**\n * <uip-back-top> — the registry BackTop as a web component.\n *\n * Class strings are React's `backTopVariants` verbatim. The host is\n * `display: contents` — the button positions itself `fixed` (viewport) or\n * `absolute` (contained), offset from the `position` corner by `offset` px.\n * The default arrow is the same ArrowUp node; slot your own <svg slot=\"icon\">\n * for React's `icon` prop. React's `ariaLabel` prop maps to the native\n * `aria-label` attribute (default `'Scroll to top'`).\n *\n * `target` is a CSS selector (attribute), HTMLElement, or Window (property) —\n * defaults to the window. The button mounts once scroll passes `threshold`\n * (`>=`, so `threshold=\"0\"` always shows) and unmounts 200ms after hiding so\n * the exit animation plays, exactly like React.\n *\n * Events: `visible` (detail: { visible } — React's `onVisible`). The click\n * still smooth-scrolls (or instant with `behavior=\"auto\"` /\n * `prefers-reduced-motion`).\n */\nexport class UipBackTop extends LitElement {\n  // The button positions itself (fixed/absolute); the host adds no box.\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  static properties = {\n    threshold: { type: Number },\n    target: { converter: { fromAttribute: (v: string | null) => v ?? undefined, toAttribute: () => null } },\n    behavior: { reflect: true },\n    size: { reflect: true },\n    position: { reflect: true },\n    offset: { type: Number },\n    absolute: { type: Boolean, reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    visible: { state: true },\n    mounted: { state: true },\n    dataState: { state: true },\n    hasSlottedIcon: { state: true },\n  }\n\n  threshold = 200\n  target?: string | HTMLElement | Window\n  behavior: ScrollBehavior = 'smooth'\n  size: Size = 'default'\n  position: Position = 'bottom-right'\n  offset = 24\n  absolute = false\n  accessibleLabel = 'Scroll to top'\n  private visible = false\n  private mounted = false\n  private dataState: 'open' | 'closed' = 'closed'\n  private hasSlottedIcon = false\n  private currentTarget: HTMLElement | Window | null = null\n  private hideTimer?: ReturnType<typeof setTimeout>\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'back-top')\n    this.currentTarget = this.resolveTarget()\n    this.attach()\n    this.handleScroll()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.detach()\n    clearTimeout(this.hideTimer)\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (changed.has('target')) {\n      this.detach()\n      this.currentTarget = this.resolveTarget()\n      this.attach()\n      this.handleScroll()\n    }\n    // Mount/unmount with exit animation, mirroring React.\n    if (changed.has('visible')) {\n      if (this.visible) {\n        clearTimeout(this.hideTimer)\n        this.mounted = true\n        this.dataState = 'open'\n      } else if (this.mounted) {\n        this.dataState = 'closed'\n        clearTimeout(this.hideTimer)\n        this.hideTimer = setTimeout(() => (this.mounted = false), 200)\n      }\n    }\n  }\n\n  private resolveTarget(): HTMLElement | Window | null {\n    if (typeof window === 'undefined') return null\n    const t = this.target\n    if (t === undefined || t === null) return window\n    if (typeof t === 'string') return document.querySelector<HTMLElement>(t) ?? window\n    return t\n  }\n\n  private getScrollTop(el: HTMLElement | Window): number {\n    if (el === window) return window.scrollY ?? document.documentElement.scrollTop ?? document.body.scrollTop ?? 0\n    return (el as HTMLElement).scrollTop\n  }\n\n  private scrollToTop(el: HTMLElement | Window) {\n    const reduceMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    const behavior: ScrollBehavior = reduceMotion ? 'auto' : this.behavior\n    if (el === window) window.scrollTo({ top: 0, behavior })\n    else (el as HTMLElement).scrollTo({ top: 0, behavior })\n  }\n\n  private handleScroll = () => {\n    const current = this.currentTarget\n    if (!current) return\n    // `>=` so threshold={0} always shows (size/position demos, forced-visible cases).\n    const next = this.getScrollTop(current) >= this.threshold\n    if (next !== this.visible) {\n      this.visible = next\n      this.dispatchEvent(new CustomEvent('visible', { detail: { visible: next }, bubbles: true, composed: true }))\n    }\n  }\n\n  private attach() {\n    const current = this.currentTarget\n    if (!current) return\n    current.addEventListener('scroll', this.handleScroll, { passive: true })\n    // If target is a container, also listen to window scroll for safety on resize.\n    if (current !== window) window.addEventListener('scroll', this.handleScroll, { passive: true })\n  }\n\n  private detach() {\n    const current = this.currentTarget\n    if (!current) return\n    current.removeEventListener('scroll', this.handleScroll)\n    if (current !== window) window.removeEventListener('scroll', this.handleScroll)\n  }\n\n  private onClick() {\n    if (this.currentTarget) this.scrollToTop(this.currentTarget)\n  }\n\n  private onIconSlotChange(e: Event) {\n    this.hasSlottedIcon = (e.target as HTMLSlotElement)\n      .assignedNodes()\n      .some((n) => n.nodeType === 1 || n.textContent?.trim())\n  }\n\n  render() {\n    if (!this.mounted) return nothing\n    const o = `${this.offset}px`\n    const positionStyle =\n      this.position === 'bottom-left'\n        ? { left: o, bottom: o }\n        : this.position === 'top-right'\n          ? { right: o, top: o }\n          : this.position === 'top-left'\n            ? { left: o, top: o }\n            : { right: o, bottom: o }\n    // React's `[&_svg]:…` sizing for slotted icons (a slotted <svg> is not a\n    // descendant of the inner button): sm → size-4, lg → size-6, else size-5.\n    const slottedIconSize =\n      this.size === 'sm'\n        ? \"[&::slotted(svg:not([class*=size-]))]:size-4\"\n        : this.size === 'lg'\n          ? \"[&::slotted(svg:not([class*=size-]))]:size-6\"\n          : \"[&::slotted(svg:not([class*=size-]))]:size-5\"\n    return html`<button\n      part=\"base\"\n      data-uipkge=\"\"\n      data-slot=\"back-top\"\n      data-state=${this.dataState}\n      data-size=${this.size}\n      data-position=${this.position}\n      type=\"button\"\n      aria-label=${this.accessibleLabel}\n      class=${cn(backTopVariants({ size: this.size, position: this.position }), this.absolute ? 'absolute' : 'fixed')}\n      style=${styleMap(positionStyle)}\n      @click=${this.onClick}\n    >\n      ${this.hasSlottedIcon ? nothing : icon(ArrowUp, 'arrow-up')}\n      <slot\n        name=\"icon\"\n        class=${cn('[&::slotted(svg)]:pointer-events-none [&::slotted(svg)]:shrink-0', slottedIconSize)}\n        @slotchange=${this.onIconSlotChange}\n      ></slot>\n    </button>`\n  }\n}\n\ncustomElements.get('uip-back-top') || customElements.define('uip-back-top', UipBackTop)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-back-top': UipBackTop\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/back-top/back-top.ts"
    },
    {
      "path": "packages/elements-lit/src/components/back-top/back-top.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\n/**\n * Variant definitions live in their own file (rather than the package\n * `index.ts`) so components can import `backTopVariants` from './back-top.variants' without creating a circular dependency through the index.\n */\nexport const backTopVariants = cva(\n  'inline-flex items-center justify-center rounded-full border bg-background text-foreground shadow-lg transition-[color,background-color,box-shadow] duration-200 hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none cursor-pointer motion-safe:data-[state=open]:animate-in motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=closed]:zoom-out-95 [&_svg:not([class*=size-])]:size-5 [&_svg]:pointer-events-none [&_svg]:shrink-0',\n  {\n    variants: {\n      size: {\n        sm: 'size-8 [&_svg:not([class*=size-])]:size-4',\n        default: 'size-10',\n        lg: 'size-12 [&_svg:not([class*=size-])]:size-6',\n      },\n      position: {\n        'bottom-right': '',\n        'bottom-left': '',\n        'top-right': '',\n        'top-left': '',\n      },\n    },\n    defaultVariants: {\n      size: 'default',\n      position: 'bottom-right',\n    },\n  },\n)\n\nexport type BackTopVariants = VariantProps<typeof backTopVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/back-top/back-top.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
