{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "horizontal-sticky-stack",
  "type": "registry:lib",
  "title": "Horizontal Sticky Stack",
  "description": "A vanilla CSS horizontal scroll section where cards stick and stack as you scroll through.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/horizontal-sticky-stack/horizontal-sticky-stack.ts",
      "type": "registry:file",
      "target": "lib/vanilla-motion/horizontal-sticky-stack/horizontal-sticky-stack.ts",
      "content": "import { defaults, type HorizontalStickyStackParams } from \"./params\";\n\nexport type { HorizontalStickyStackParams } from \"./params\";\nexport { defaults } from \"./params\";\n\n/** One card. */\nexport type StackItem = {\n  title: string;\n  text: string;\n  /** Image URL. */\n  image?: string;\n  /** Card background, any CSS color. Cycles through four pastels when omitted. */\n  color?: string;\n  /** Text in the vertical strip beside the number. */\n  label?: string;\n};\n\nexport type HorizontalStickyStackOptions = Partial<HorizontalStickyStackParams> & {\n  /**\n   * Cards to show. When omitted, the stack reads the container's own\n   * [data-horizontal-sticky-stack-item] children, the markup way: each takes data-color,\n   * data-label and data-image (or an <img>), the first heading as title, the first <p> as text.\n   */\n  items?: StackItem[];\n  /**\n   * Make the container itself the scroller (overflow-y: auto), for a host with a fixed height\n   * such as the Toolcraft canvas (adds one view of empty space after the stack so it can finish\n   * sliding). Leave off on a normal page: the page scrolls and the stack\n   * pins to the viewport.\n   */\n  scroll?: boolean;\n};\n\n/** The public surface wrappers use (Toolcraft canvas, embed). */\nexport interface HorizontalStickyStackHandle {\n  setParams(next: Partial<HorizontalStickyStackParams>): void;\n  dispose(): void;\n}\n\n/**\n * Fill an element with the stack: replaces its content with the cards and starts nothing else.\n * The motion is pure CSS (a scroll-driven animation on a view timeline), so there is no loop to run.\n * Used by the Toolcraft app, the embed page and the embed script.\n */\nexport function mount(container: HTMLElement, options: HorizontalStickyStackOptions = {}): HorizontalStickyStackHandle {\n  const fx = new HorizontalStickyStack(container, options);\n  return { setParams: (next) => fx.setParams(next), dispose: () => fx.dispose() };\n}\n\nconst COLORS = [\"#FFD6E0\", \"#C1F0C8\", \"#BDE0FE\", \"#FEF3BD\"];\nconst ITEM_ATTR = \"data-horizontal-sticky-stack-item\";\n\nconst STYLE_ID = \"hss-style\";\n// The tutorial's Tailwind classes, written out. Desktop is 768px and wider (Tailwind md), the\n// column widens at 1024px (lg). --total and --index are set inline by the class.\nconst STYLE = `\n.hss,.hss *{box-sizing:border-box}\n.hss{position:relative;font-family:ui-sans-serif,system-ui,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";view-timeline-name:--hss;color:var(--hss-text);background:var(--hss-bg);width:100%}\n.hss-notice{display:block;position:absolute;top:0;left:0;width:100%;z-index:40;padding:8px;text-align:center;background:rgba(0,0,0,.2);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}\n.hss-item{--hss-reverse:calc(var(--total) - var(--index))}\n.hss-card{display:flex;overflow:hidden;background:var(--bg);border-radius:var(--hss-radius);width:100%;height:100%}\n.hss-spine{width:var(--hss-spine);display:flex;align-items:flex-end;justify-content:center}\n.hss-spine p{margin:0;font-size:14px;line-height:20px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;writing-mode:vertical-lr;transform:scale(-1);color:var(--hss-muted)}\n.hss-spine .hss-num{display:inline-block;margin-bottom:8px}\n.hss-body{width:calc(100% - var(--hss-spine));display:grid;grid-template-columns:repeat(12,minmax(0,1fr))}\n.hss-media{grid-column:span 3;position:relative;overflow:hidden;background:var(--hss-media);border-radius:calc(var(--hss-radius) / 2)}\n.hss-media img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}\n.hss-text{grid-column:span 9}\n.hss-copy{padding:24px}\n.hss-copy>*+*{margin-top:24px}\n.hss-copy h2{margin:0;font-size:24px;line-height:1.3333;font-weight:700;letter-spacing:-.025em;color:inherit}\n.hss-copy p{margin:0;max-width:42rem;font-size:16px;line-height:1.5;color:var(--hss-muted)}\n@keyframes hss-scale{to{transform:scale(calc(1.1 - calc(.1 * var(--hss-reverse))))}}\n@keyframes hss-slide{to{transform:translateX(var(--to-x))}}\n@media (min-width:768px){\n.hss{--h:var(--hss-h-small);--w:var(--hss-w-small);height:calc(var(--h) * var(--total))}\n.hss-sticky{position:sticky;top:0;overflow:hidden;display:flex;align-items:center;padding:48px 0 48px clamp(20px, calc((100% - var(--hss-container)) / 2), 100%);min-height:var(--hss-view,100vh)}\n.hss-stack{position:relative;height:var(--h);width:var(--w)}\n.hss-item{--end-range:calc(70% / var(--total) * var(--index));--to-x:calc(var(--index) * var(--hss-spine));position:absolute;top:0;left:0;height:var(--h);width:var(--w);transform:translateX(calc(100% * var(--index) + var(--hss-gap) * var(--index)));animation:linear hss-slide forwards;animation-timeline:--hss;animation-range:exit-crossing 0% exit-crossing var(--end-range);animation-fill-mode:both}\n.hss-card{padding:24px 0}\n.hss-copy h2{font-size:30px;line-height:1.2}\n.hss-copy>*+*{margin-top:24px}\n}\n@media (min-width:1024px){\n.hss{--h:var(--hss-h);--w:var(--hss-container)}\n.hss-media{grid-column:span 4}\n.hss-text{grid-column:span 8}\n.hss-copy h2{font-size:48px;line-height:1}\n.hss-copy p{font-size:18px;line-height:1.5556}\n}\n@media (max-width:767.98px){\n.hss{height:auto;padding:0 20px}\n.hss-stack{display:flex;flex-direction:column;padding-bottom:calc(var(--total) * var(--hss-stack-gap))}\n.hss-item{position:sticky;top:0;height:auto;width:100%;padding-top:calc((var(--index) + 1) * var(--hss-stack-gap));margin-bottom:calc((var(--index) + 1) * var(--hss-stack-gap) * -1)}\n.hss-card{--start-range:calc(100% / var(--total) * var(--index));--end-range:calc(100% / var(--total) * (var(--index) + 1));padding:16px;border-radius:calc(var(--hss-radius) * .75);transform-origin:top;height:calc(var(--hss-view,100vh) * .65);min-height:var(--hss-card-min);animation:ease-in hss-scale forwards;animation-timeline:--hss;animation-range:exit-crossing var(--start-range) exit-crossing var(--end-range)}\n.hss-spine{display:none}\n.hss-body{width:100%;display:flex;flex-direction:column}\n.hss-media{aspect-ratio:16/10;border-radius:calc(var(--hss-radius) * .375)}\n.hss-copy{padding:16px 0}\n.hss-copy>*+*{margin-top:12px}\n}\n.hss.hss-static{height:auto;padding:0 24px;margin:0 auto;max-width:1440px}\n.hss.hss-static .hss-sticky{position:static;display:block;overflow:visible;padding:0;min-height:0}\n.hss.hss-static .hss-stack{display:flex;flex-direction:column;gap:24px;position:static;height:auto;width:auto;padding:0}\n.hss.hss-static .hss-item{position:static;transform:none;animation:none;height:auto;width:auto;margin:0;padding:0}\n.hss.hss-static .hss-card{animation:none;height:auto;min-height:0}\n@media (min-width:768px){.hss.hss-static .hss-card{min-height:360px}}\n@media (min-width:1024px){.hss.hss-static .hss-card{min-height:500px}}\n`;\n\nconst px = (n: number) => `${n}px`;\n\nfunction toRgba(css: string, opacity: number): string {\n  const m = /^#([0-9a-f]{6})$/i.exec(css.trim());\n  if (!m) return opacity >= 1 ? css : `color-mix(in srgb, ${css} ${opacity * 100}%, transparent)`;\n  const n = parseInt(m[1], 16);\n  return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${opacity})`;\n}\n\nfunction readItems(container: HTMLElement): StackItem[] {\n  return [...container.querySelectorAll<HTMLElement>(`[${ITEM_ATTR}]`)].map((el) => ({\n    title: el.querySelector(\"h1,h2,h3,h4,h5,h6\")?.textContent?.trim() ?? \"\",\n    text: el.querySelector(\"p\")?.textContent?.trim() ?? \"\",\n    image: el.dataset.image ?? el.querySelector(\"img\")?.getAttribute(\"src\") ?? undefined,\n    color: el.dataset.color,\n    label: el.dataset.label,\n  }));\n}\n\nfunction el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {\n  const node = document.createElement(tag);\n  if (className) node.className = className;\n  if (text !== undefined) node.textContent = text;\n  return node;\n}\n\nexport class HorizontalStickyStack {\n  static readonly version = \"1.0.0\";\n\n  private readonly container: HTMLElement;\n  private readonly root: HTMLElement;\n  private readonly params: HorizontalStickyStackParams;\n  private readonly saved: { overflowY: string; html: string };\n  private readonly resize: ResizeObserver | null = null;\n\n  constructor(container: HTMLElement, options: HorizontalStickyStackOptions = {}) {\n    const { items, scroll, ...params } = options;\n    this.container = container;\n    this.params = { ...defaults, ...params };\n    const cards = items ?? readItems(container);\n    this.saved = { overflowY: container.style.overflowY, html: container.innerHTML };\n\n    if (!document.getElementById(STYLE_ID)) {\n      const style = el(\"style\");\n      style.id = STYLE_ID;\n      style.textContent = STYLE;\n      document.head.appendChild(style);\n    }\n\n    this.root = el(\"section\", \"hss\");\n    this.root.style.setProperty(\"--total\", String(cards.length));\n    const sticky = el(\"div\", \"hss-sticky\");\n    const stack = el(\"div\", \"hss-stack\");\n    cards.forEach((card, index) => stack.appendChild(this.buildCard(card, index)));\n    sticky.appendChild(stack);\n    this.root.appendChild(sticky);\n    if (typeof CSS !== \"undefined\" && !CSS.supports(\"animation-timeline\", \"scroll()\")) {\n      this.root.prepend(el(\"div\", \"hss-notice\", \"Your browser doesn't support CSS scroll-driven animations.\"));\n    }\n    container.replaceChildren(this.root);\n\n    if (scroll) {\n      container.style.overflowY = \"auto\";\n      // The slide only finishes while the section leaves the top, so the scroller needs room after it.\n      const tail = el(\"div\");\n      container.appendChild(tail);\n      const measure = () => {\n        this.root.style.setProperty(\"--hss-view\", px(container.clientHeight));\n        tail.style.height = px(container.clientHeight);\n      };\n      measure();\n      this.resize = new ResizeObserver(measure);\n      this.resize.observe(container);\n    }\n    this.apply();\n  }\n\n  private buildCard(card: StackItem, index: number): HTMLElement {\n    const item = el(\"div\", \"hss-item\");\n    item.style.setProperty(\"--index\", String(index));\n    const content = el(\"div\", \"hss-card\");\n    content.style.setProperty(\"--bg\", card.color ?? COLORS[index % COLORS.length]);\n\n    const spine = el(\"div\", \"hss-spine\");\n    const label = el(\"p\");\n    label.append(el(\"span\", \"hss-num\", String(index + 1).padStart(2, \"0\")));\n    if (card.label) label.append(el(\"span\", undefined, card.label));\n    spine.appendChild(label);\n\n    const body = el(\"div\", \"hss-body\");\n    const media = el(\"div\", \"hss-media\");\n    if (card.image) {\n      const img = el(\"img\");\n      img.src = card.image;\n      img.alt = \"\";\n      img.loading = \"lazy\";\n      media.appendChild(img);\n    }\n    const text = el(\"div\", \"hss-text\");\n    const copy = el(\"div\", \"hss-copy\");\n    copy.append(el(\"h2\", undefined, card.title), el(\"p\", undefined, card.text));\n    text.appendChild(copy);\n    body.append(media, text);\n\n    content.append(spine, body);\n    item.appendChild(content);\n    return item;\n  }\n\n  /** Write the params as CSS custom properties; the stylesheet does the rest. */\n  private apply(): void {\n    const p = this.params;\n    const s = this.root.style;\n    s.setProperty(\"--hss-container\", px(p.containerWidth));\n    s.setProperty(\"--hss-h\", px(p.cardHeight));\n    s.setProperty(\"--hss-h-small\", px(p.cardHeightSmall));\n    s.setProperty(\"--hss-w-small\", px(p.cardWidthSmall));\n    s.setProperty(\"--hss-spine\", px(p.spineWidth));\n    s.setProperty(\"--hss-gap\", px(p.cardGap));\n    s.setProperty(\"--hss-radius\", px(p.cornerRadius));\n    s.setProperty(\"--hss-stack-gap\", px(p.mobileStackGap));\n    s.setProperty(\"--hss-card-min\", px(p.mobileCardMinHeight));\n    s.setProperty(\"--hss-bg\", toRgba(p.bgColor, p.bgOpacity));\n    s.setProperty(\"--hss-text\", p.textColor);\n    s.setProperty(\"--hss-muted\", p.mutedColor);\n    s.setProperty(\"--hss-media\", p.mediaColor);\n    this.root.classList.toggle(\"hss-static\", p.layout === \"static\");\n  }\n\n  setParams(next: Partial<HorizontalStickyStackParams>): void {\n    Object.assign(this.params, next);\n    this.apply();\n  }\n\n  dispose(): void {\n    this.resize?.disconnect();\n    this.container.style.overflowY = this.saved.overflowY;\n    this.container.innerHTML = this.saved.html;\n  }\n}\n"
    },
    {
      "path": "registry/horizontal-sticky-stack/params.ts",
      "type": "registry:file",
      "target": "lib/vanilla-motion/horizontal-sticky-stack/params.ts",
      "content": "// The one definition of every Horizontal Sticky Stack value. The class merges options over\n// `defaults`, horizontal-sticky-stack-controls.ts builds the Toolcraft panel from the same keys,\n// and the embed reads the same keys as kebab-case data attributes (data-card-height, data-corner-radius…).\n// All lengths are CSS pixels. The breakpoints (768px and 1024px) are fixed in the class's CSS.\n\nexport type HorizontalStickyStackParams = {\n  /**\n   * 'stack': the scroll-driven stack. 'static': the same cards as a plain vertical list with\n   * no pinning and no animation (the tutorial's step 2: the layout before it moves).\n   */\n  layout: \"stack\" | \"static\";\n  // size (desktop: 768px and wider)\n  /** Width of the centred column on wide screens; the card is as wide as the column from 1024px. */\n  containerWidth: number;\n  /** Card height from 1024px. */\n  cardHeight: number;\n  /** Card height between 768px and 1024px. */\n  cardHeightSmall: number;\n  /** Card width between 768px and 1024px. */\n  cardWidthSmall: number;\n  /** Width of the vertical label strip. Also how far each card peeks out after it stacks. */\n  spineWidth: number;\n  /** Space between the cards before they slide together. */\n  cardGap: number;\n  cornerRadius: number;\n  // size (mobile: under 768px)\n  /** Space each card leaves above itself when it sticks, per card. */\n  mobileStackGap: number;\n  /** Smallest card height on mobile (it is 65% of the view otherwise). */\n  mobileCardMinHeight: number;\n  // color (any CSS color; the Toolcraft panel writes #RRGGBB)\n  bgColor: string;\n  /** Opacity of bgColor; 0 leaves the host page showing through. */\n  bgOpacity: number;\n  textColor: string;\n  mutedColor: string;\n  /** Behind the image while it loads. */\n  mediaColor: string;\n};\n\nexport const defaults: HorizontalStickyStackParams = {\n  layout: \"stack\",\n  containerWidth: 1200, cardHeight: 450, cardHeightSmall: 360, cardWidthSmall: 800,\n  spineWidth: 50, cardGap: 20, cornerRadius: 32,\n  mobileStackGap: 10, mobileCardMinHeight: 500,\n  bgColor: \"#FFFFFF\", bgOpacity: 1, textColor: \"#18181B\", mutedColor: \"#6A7282\", mediaColor: \"#E2E8F0\",\n};\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "entry": "horizontal-sticky-stack.ts",
    "docs": "https://vanilla-motion.com/horizontal-sticky-stack/skill.md"
  }
}
