{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vertical-marquee",
  "title": "Vertical Marquee",
  "description": "Two columns of tweet cards scrolling vertically in opposite directions with progressive fade at the edges.",
  "dependencies": [
    "motion",
    "react-tweet"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/components/unlumen/vertical-marquee/index.tsx",
      "content": "\"use client\";\nimport { getTweet, type Tweet } from \"react-tweet/api\";\n\nimport {\n  TweetNotFound,\n  VerticalMarqueeClient,\n} from \"./vertical-marquee-client\";\nimport type { TweetItem } from \"./vertical-marquee.types\";\n\nexport type { TweetItem } from \"./vertical-marquee.types\";\n\nexport interface VerticalMarqueeProps {\n  /** Tweet IDs to display in the scrolling columns. */\n  tweetIds: string[];\n  /** Number of scrolling columns. @default 2 */\n  columns?: 1 | 2;\n  /** Scroll duration in seconds per full loop. @default 20 */\n  speed?: number;\n  /** Vertical gap between cards in px. @default 16 */\n  gap?: number;\n  /** Height of the fade zone at top and bottom in px. @default 120 */\n  blurSize?: number;\n  /** Smoothly decelerate to a stop when hovering. @default true */\n  pauseOnHover?: boolean;\n  className?: string;\n  tweetClassName?: string;\n}\n\nasync function getTweetItem(id: string): Promise<TweetItem> {\n  const tweet = await getTweet(id).catch((error) => {\n    console.error(error);\n    return undefined;\n  });\n\n  return { id, tweet };\n}\n\nexport async function VerticalMarquee({\n  tweetIds,\n  columns = 2,\n  speed = 20,\n  gap = 16,\n  blurSize = 120,\n  pauseOnHover = true,\n  className,\n  tweetClassName,\n}: VerticalMarqueeProps) {\n  if (!tweetIds.length) {\n    return null;\n  }\n\n  const tweets = await Promise.all(tweetIds.map((id) => getTweetItem(id)));\n  const hasResolvedTweet = tweets.some((item) => item.tweet);\n\n  if (!hasResolvedTweet) {\n    return (\n      <div className=\"grid gap-4 sm:grid-cols-2\">\n        {tweetIds.slice(0, Math.min(tweetIds.length, 4)).map((id) => (\n          <TweetNotFound key={id} />\n        ))}\n      </div>\n    );\n  }\n\n  return (\n    <VerticalMarqueeClient\n      tweets={tweets}\n      columns={columns}\n      speed={speed}\n      gap={gap}\n      blurSize={blurSize}\n      pauseOnHover={pauseOnHover}\n      className={className}\n      tweetClassName={tweetClassName}\n    />\n  );\n}\n\nexport {\n  MagicTweet,\n  TweetBody,\n  TweetHeader,\n  TweetMedia,\n  TweetNotFound,\n  TweetSkeleton,\n  VerticalMarqueeClient,\n} from \"./vertical-marquee-client\";\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/vertical-marquee.tsx"
    },
    {
      "path": "registry/components/unlumen/vertical-marquee/vertical-marquee-client.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useAnimationFrame, useMotionValue } from \"motion/react\";\nimport { enrichTweet, type EnrichedTweet } from \"react-tweet\";\n\nimport type { Tweet } from \"react-tweet/api\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport type { TweetItem } from \"./vertical-marquee.types\";\n\ninterface TwitterIconProps {\n  className?: string;\n  [key: string]: unknown;\n}\n\nconst Twitter = ({ className, ...props }: TwitterIconProps) => (\n  <svg\n    stroke=\"currentColor\"\n    fill=\"currentColor\"\n    strokeWidth=\"0\"\n    viewBox=\"0 0 24 24\"\n    height=\"1em\"\n    width=\"1em\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    className={className}\n    {...props}\n  >\n    <g>\n      <path fill=\"none\" d=\"M0 0h24v24H0z\" />\n      <path d=\"M22.162 5.656a8.384 8.384 0 0 1-2.402.658A4.196 4.196 0 0 0 21.6 4c-.82.488-1.719.83-2.656 1.015a4.182 4.182 0 0 0-7.126 3.814 11.874 11.874 0 0 1-8.62-4.37 4.168 4.168 0 0 0-.566 2.103c0 1.45.738 2.731 1.86 3.481a4.168 4.168 0 0 1-1.894-.523v.052a4.185 4.185 0 0 0 3.355 4.101 4.21 4.21 0 0 1-1.89.072A4.185 4.185 0 0 0 7.97 16.65a8.394 8.394 0 0 1-6.191 1.732 11.83 11.83 0 0 0 6.41 1.88c7.693 0 11.9-6.373 11.9-11.9 0-.18-.005-.362-.013-.54a8.496 8.496 0 0 0 2.087-2.165z\" />\n    </g>\n  </svg>\n);\n\nconst Verified = ({ className, ...props }: TwitterIconProps) => (\n  <svg\n    aria-label=\"Verified Account\"\n    viewBox=\"0 0 24 24\"\n    className={className}\n    {...props}\n  >\n    <g fill=\"currentColor\">\n      <path d=\"M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.818-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.437 2.25c-.415-.165-.866-.25-1.336-.25-2.11 0-3.818 1.79-3.818 4 0 .494.083.964.237 1.4-1.272.65-2.147 2.018-2.147 3.6 0 1.495.782 2.798 1.942 3.486-.02.17-.032.34-.032.514 0 2.21 1.708 4 3.818 4 .47 0 .92-.086 1.335-.25.62 1.334 1.926 2.25 3.437 2.25 1.512 0 2.818-.916 3.437-2.25.415.163.865.248 1.336.248 2.11 0 3.818-1.79 3.818-4 0-.174-.012-.344-.033-.513 1.158-.687 1.943-1.99 1.943-3.484zm-6.616-3.334l-4.334 6.5c-.145.217-.382.334-.625.334-.143 0-.288-.04-.416-.126l-.115-.094-2.415-2.415c-.293-.293-.293-.768 0-1.06s.768-.294 1.06 0l1.77 1.767 3.825-5.74c.23-.345.696-.436 1.04-.207.346.23.44.696.21 1.04z\" />\n    </g>\n  </svg>\n);\n\nexport const truncate = (str: string | null, length: number) => {\n  if (!str || str.length <= length) return str;\n  return `${str.slice(0, length - 3)}...`;\n};\n\nconst Skeleton = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => {\n  return (\n    <div className={cn(\"rounded-md bg-primary/10\", className)} {...props} />\n  );\n};\n\nexport const TweetSkeleton = ({\n  className,\n  ...props\n}: {\n  className?: string;\n  [key: string]: unknown;\n}) => (\n  <div\n    className={cn(\n      \"flex size-full max-h-max min-w-72 flex-col gap-3 rounded-[28px] border border-border/60 bg-background/90 p-4 backdrop-blur-sm\",\n      className,\n    )}\n    {...props}\n  >\n    <div className=\"flex flex-row gap-3\">\n      <Skeleton className=\"size-11 shrink-0 rounded-full\" />\n      <Skeleton className=\"h-11 w-full\" />\n    </div>\n    <Skeleton className=\"h-24 w-full\" />\n    <Skeleton className=\"h-44 w-full rounded-2xl\" />\n  </div>\n);\n\nexport const TweetNotFound = ({\n  className,\n  ...props\n}: {\n  className?: string;\n  [key: string]: unknown;\n}) => (\n  <div\n    className={cn(\n      \"flex min-h-56 size-full flex-col items-center justify-center gap-2 rounded-[28px] border border-dashed border-border/70 bg-muted/40 p-6 text-center\",\n      className,\n    )}\n    {...props}\n  >\n    <h3 className=\"text-base font-medium\">Tweet not found</h3>\n    <p className=\"text-muted-foreground text-sm\">\n      This post could not be loaded.\n    </p>\n  </div>\n);\n\nexport const TweetHeader = ({ tweet }: { tweet: EnrichedTweet }) => (\n  <div className=\"flex flex-row items-start justify-between tracking-normal\">\n    <div className=\"flex items-center space-x-3\">\n      <a\n        href={tweet.user.url}\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        className=\"shrink-0\"\n      >\n        <img\n          title={`Profile picture of ${tweet.user.name}`}\n          alt={tweet.user.screen_name}\n          height={48}\n          width={48}\n          src={tweet.user.profile_image_url_https}\n          className=\"overflow-hidden rounded-full\"\n        />\n      </a>\n      <div className=\"flex flex-col gap-0.5\">\n        <a\n          href={tweet.user.url}\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          className=\"text-foreground flex items-center font-medium whitespace-nowrap transition-opacity hover:opacity-80\"\n        >\n          {truncate(tweet.user.name, 20)}\n          {(tweet.user.verified || tweet.user.is_blue_verified) && (\n            <Verified className=\"ml-1 inline size-4 text-sky-500\" />\n          )}\n        </a>\n        <div className=\"flex items-center space-x-1\">\n          <a\n            href={tweet.user.url}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"text-muted-foreground hover:text-foreground text-sm transition-colors\"\n          >\n            @{truncate(tweet.user.screen_name, 16)}\n          </a>\n        </div>\n      </div>\n    </div>\n    <a href={tweet.url} target=\"_blank\" rel=\"noreferrer\">\n      <span className=\"sr-only\">Link to tweet</span>\n      <Twitter className=\"text-muted-foreground hover:text-foreground size-5 transition-transform hover:scale-105\" />\n    </a>\n  </div>\n);\n\nexport const TweetBody = ({ tweet }: { tweet: EnrichedTweet }) => (\n  <div className=\"whitespace-pre-wrap break-words text-[15px] leading-relaxed tracking-normal\">\n    {tweet.entities.map((entity, idx) => {\n      switch (entity.type) {\n        case \"url\":\n        case \"symbol\":\n        case \"hashtag\":\n        case \"mention\":\n          return (\n            <a\n              key={idx}\n              href={entity.href}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-muted-foreground hover:text-foreground text-[15px] font-normal transition-colors\"\n            >\n              <span>{entity.text}</span>\n            </a>\n          );\n        case \"text\":\n          return (\n            <span\n              key={idx}\n              className=\"text-foreground text-[15px] font-normal\"\n              dangerouslySetInnerHTML={{ __html: entity.text }}\n            />\n          );\n        default:\n          return null;\n      }\n    })}\n  </div>\n);\n\nfunction getCardThumbnail(tweet: unknown) {\n  const card = tweet as {\n    card?: {\n      binding_values?: {\n        thumbnail_image_large?: {\n          image_value?: {\n            url?: string;\n          };\n        };\n      };\n    };\n  };\n\n  return card.card?.binding_values?.thumbnail_image_large?.image_value?.url;\n}\n\nfunction getBestVideoSource(tweet: EnrichedTweet) {\n  const variants = tweet.video?.variants ?? [];\n  const mp4Variants = variants.filter((variant) =>\n    variant.src.includes(\".mp4\"),\n  );\n\n  return (\n    mp4Variants.at(-1)?.src ?? variants.find((variant) => variant.src)?.src\n  );\n}\n\nfunction normalizeTweet(tweet: Tweet): Tweet {\n  return {\n    ...tweet,\n    display_text_range: [...tweet.display_text_range],\n    entities: {\n      hashtags: tweet.entities.hashtags ?? [],\n      urls: tweet.entities.urls ?? [],\n      user_mentions: tweet.entities.user_mentions ?? [],\n      symbols: tweet.entities.symbols ?? [],\n      media: tweet.entities.media,\n    },\n    quoted_tweet: tweet.quoted_tweet\n      ? normalizeQuotedTweet(tweet.quoted_tweet)\n      : undefined,\n  };\n}\n\nfunction normalizeQuotedTweet(\n  tweet: NonNullable<Tweet[\"quoted_tweet\"]>,\n): NonNullable<Tweet[\"quoted_tweet\"]> {\n  return {\n    ...tweet,\n    display_text_range: [...tweet.display_text_range],\n    entities: {\n      hashtags: tweet.entities.hashtags ?? [],\n      urls: tweet.entities.urls ?? [],\n      user_mentions: tweet.entities.user_mentions ?? [],\n      symbols: tweet.entities.symbols ?? [],\n      media: tweet.entities.media,\n    },\n  };\n}\n\nexport const TweetMedia = ({ tweet }: { tweet: EnrichedTweet }) => {\n  const thumbnail = getCardThumbnail(tweet);\n  const videoSource = getBestVideoSource(tweet);\n\n  if (!tweet.video && !tweet.photos?.length && !thumbnail) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex flex-1 items-center justify-center\">\n      {tweet.video ? (\n        <video\n          poster={tweet.video.poster}\n          autoPlay\n          loop\n          muted\n          playsInline\n          className=\"w-full rounded-2xl border border-border/60\"\n        >\n          {videoSource ? <source src={videoSource} type=\"video/mp4\" /> : null}\n          Your browser does not support the video tag.\n        </video>\n      ) : tweet.photos?.length ? (\n        <div className=\"relative flex w-full snap-x snap-mandatory gap-3 overflow-x-auto pb-1\">\n          {tweet.photos.map((photo) => (\n            <img\n              key={photo.url}\n              src={photo.url}\n              width={photo.width}\n              height={photo.height}\n              title={`Photo by ${tweet.user.name}`}\n              alt={tweet.text}\n              className=\"h-64 w-[88%] shrink-0 snap-center rounded-2xl border border-border/60 object-cover\"\n            />\n          ))}\n        </div>\n      ) : (\n        <img\n          src={thumbnail}\n          className=\"h-64 w-full rounded-2xl border border-border/60 object-cover\"\n          alt={tweet.text}\n        />\n      )}\n    </div>\n  );\n};\n\nexport const MagicTweet = ({\n  tweet,\n  className,\n  ...props\n}: {\n  tweet: Tweet;\n  className?: string;\n}) => {\n  const enrichedTweet = enrichTweet(normalizeTweet(tweet));\n\n  return (\n    <article\n      className={cn(\n        \"relative flex h-fit w-full flex-col gap-4 overflow-hidden rounded-[28px] border border-border/60 bg-background/95 p-5 backdrop-blur-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <TweetHeader tweet={enrichedTweet} />\n      <TweetBody tweet={enrichedTweet} />\n      <TweetMedia tweet={enrichedTweet} />\n    </article>\n  );\n};\n\ninterface ColumnProps {\n  tweets: TweetItem[];\n  speed: number;\n  gap: number;\n  reverse?: boolean;\n  paused: boolean;\n  tweetClassName?: string;\n}\n\nfunction MarqueeColumn({\n  tweets,\n  speed,\n  gap,\n  reverse = false,\n  paused,\n  tweetClassName,\n}: ColumnProps) {\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const [singleHeight, setSingleHeight] = React.useState(0);\n  const y = useMotionValue(0);\n  const pos = React.useRef(0);\n  const currentSpeed = React.useRef(0);\n  const initialized = React.useRef(false);\n\n  React.useEffect(() => {\n    const el = wrapRef.current;\n    if (!el) return;\n\n    const measure = () => {\n      const height = el.scrollHeight / 2;\n      if (height <= 0) return;\n      setSingleHeight(height);\n      if (!initialized.current) {\n        pos.current = reverse ? -height : 0;\n        y.set(pos.current);\n        initialized.current = true;\n      }\n    };\n\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    measure();\n\n    return () => observer.disconnect();\n  }, [reverse, tweets, y]);\n\n  useAnimationFrame((_, delta) => {\n    if (!singleHeight) return;\n\n    const targetSpeed = paused ? 0 : singleHeight / speed;\n    currentSpeed.current += (targetSpeed - currentSpeed.current) * 0.08;\n\n    const step = currentSpeed.current * (delta / 1000);\n    if (reverse) {\n      pos.current += step;\n      if (pos.current >= 0) pos.current -= singleHeight;\n    } else {\n      pos.current -= step;\n      if (pos.current <= -singleHeight) pos.current += singleHeight;\n    }\n\n    y.set(pos.current);\n  });\n\n  const doubled = [...tweets, ...tweets];\n\n  return (\n    <div className=\"min-w-0 flex-1 overflow-hidden\">\n      <motion.div ref={wrapRef} style={{ y }}>\n        {doubled.map((item, index) => (\n          <div\n            key={`${item.id}-${index}`}\n            style={{ marginBottom: `${gap}px` }}\n            className=\"w-full\"\n          >\n            {item.tweet ? (\n              <MagicTweet tweet={item.tweet} className={tweetClassName} />\n            ) : (\n              <TweetNotFound className={tweetClassName} />\n            )}\n          </div>\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n\nexport function VerticalMarqueeClient({\n  tweets,\n  columns = 2,\n  speed = 20,\n  gap = 16,\n  blurSize = 120,\n  pauseOnHover = true,\n  className,\n  tweetClassName,\n}: {\n  tweets: TweetItem[];\n  columns?: 1 | 2;\n  speed?: number;\n  gap?: number;\n  blurSize?: number;\n  pauseOnHover?: boolean;\n  className?: string;\n  tweetClassName?: string;\n}) {\n  const [paused, setPaused] = React.useState(false);\n\n  const mid = Math.ceil(tweets.length / 2);\n  const col1 = columns === 2 ? tweets.slice(0, mid) : tweets;\n  const col2 = columns === 2 ? tweets.slice(mid) : tweets;\n\n  const mask = `linear-gradient(to bottom, transparent 0%, black ${blurSize}px, black calc(100% - ${blurSize}px), transparent 100%)`;\n\n  return (\n    <div\n      className={cn(\"h-full w-full overflow-hidden\", className)}\n      style={{ maskImage: mask, WebkitMaskImage: mask }}\n      onMouseEnter={() => pauseOnHover && setPaused(true)}\n      onMouseLeave={() => setPaused(false)}\n    >\n      <div className=\"flex h-full\" style={{ gap: `${gap}px` }}>\n        <MarqueeColumn\n          tweets={col1.length ? col1 : tweets}\n          speed={speed}\n          gap={gap}\n          paused={paused}\n          tweetClassName={tweetClassName}\n        />\n        {columns === 2 && (\n          <MarqueeColumn\n            tweets={col2.length ? col2 : tweets}\n            speed={speed * 1.25}\n            gap={gap}\n            reverse\n            paused={paused}\n            tweetClassName={tweetClassName}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/vertical-marquee-client.tsx"
    },
    {
      "path": "registry/components/unlumen/vertical-marquee/vertical-marquee.types.ts",
      "content": "import type { Tweet } from \"react-tweet/api\";\n\nexport interface TweetItem {\n  id: string;\n  tweet?: Tweet;\n}\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/vertical-marquee.types.ts"
    }
  ],
  "type": "registry:ui"
}