{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "effect-01",
  "title": "Fan stack",
  "description": "A stack of cards that fans out under the pointer or the keyboard focus, each card turning around a point below the stack one after another, and closes again when they leave; a tap opens it on a phone.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/sample-scenes.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/effect/effect-01.tsx",
      "content": "'use client';\n\nimport {\n  Children,\n  isValidElement,\n  useRef,\n  useState,\n  type ReactNode,\n} from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport { hundredthsOf } from './_parts/rounded';\nimport { useHover } from './_parts/use-hover';\n\ntype CardPose = React.CSSProperties & {\n  transform: string;\n  transformOrigin: string;\n  transitionDelay: string;\n  zIndex: number;\n};\n\ntype FanPlace = {\n  angle: number;\n  offset: number;\n};\n\nconst DEFAULT_SPREAD_DEG = 20;\n\nconst DEFAULT_GAP_PX = 10;\n\nconst STAGGER_MS = 35;\n\n/** Every card turns around the same point: under the middle of the stack, a little less than one card height below it. */\nconst PIVOT = '50% 190%';\n\n/** At rest the cards behind the front one lean a little to either side, so the stack reads as a pile of cards. */\nconst REST_LEAN_DEG = 2;\n\nfunction alternatingSignOf(index: number) {\n  if (index % 2 === 0) {\n    return 1;\n  }\n  return -1;\n}\n\nfunction restAngleOf(index: number) {\n  if (index === 0) {\n    return 0;\n  }\n  const sign = alternatingSignOf(index);\n  const fading = 1 / Math.sqrt(index);\n  return sign * REST_LEAN_DEG * fading;\n}\n\n/** The front card ends at the right end of the fan, each card behind it one step to its left. */\nfunction fanPlaceOf(\n  index: number,\n  count: number,\n  spread: number,\n  gap: number\n): FanPlace {\n  const steps = count - 1;\n  if (steps < 1) {\n    return { angle: 0, offset: 0 };\n  }\n  const slot = steps - index;\n  const share = slot / steps - 0.5;\n  const fromMiddle = slot - steps / 2;\n  return { angle: share * spread, offset: fromMiddle * gap };\n}\n\n/** The front card leads on the way out and the back card on the way in, so the cards never cross. */\nfunction delayOf(index: number, count: number, isFannedOut: boolean) {\n  if (isFannedOut) {\n    return index * STAGGER_MS;\n  }\n  const fromBack = count - 1 - index;\n  return fromBack * STAGGER_MS;\n}\n\nfunction placeOf(\n  index: number,\n  count: number,\n  isFannedOut: boolean,\n  spread: number,\n  gap: number\n): FanPlace {\n  if (isFannedOut) {\n    return fanPlaceOf(index, count, spread, gap);\n  }\n  return { angle: restAngleOf(index), offset: 0 };\n}\n\nfunction poseOf(\n  index: number,\n  count: number,\n  isFannedOut: boolean,\n  spread: number,\n  gap: number\n): CardPose {\n  const place = placeOf(index, count, isFannedOut, spread, gap);\n  const offset = hundredthsOf(place.offset);\n  const angle = hundredthsOf(place.angle);\n  const delay = delayOf(index, count, isFannedOut);\n  return {\n    transform: `translateX(${offset}px) rotate(${angle}deg)`,\n    transformOrigin: PIVOT,\n    transitionDelay: `${delay}ms`,\n    zIndex: count - index,\n  };\n}\n\n/** A card keeps its own key, so a stack whose cards change order moves each card instead of swapping their faces. */\nfunction keyOf(card: ReactNode, index: number) {\n  const hasKey = isValidElement(card) && card.key !== null;\n  if (hasKey) {\n    return card.key;\n  }\n  return index;\n}\n\nexport type FanStackProps = {\n  /** The cards, the front one first. */\n  children: ReactNode;\n  /** What the stack holds, read out when the keyboard reaches it: \"Four wallpapers\". */\n  label: string;\n  /** The angle between the two outermost cards once fanned out, in degrees. */\n  spread?: number;\n  /** The distance between two neighbouring cards once fanned out, in pixels. */\n  gap?: number;\n  /** Fans the cards out with no pointer on them, for a demonstration. */\n  isFanned?: boolean;\n  className?: string;\n};\n\n/**\n * A stack of cards that fans out under the pointer or the keyboard focus and closes again when they\n * leave. A finger cannot hover, so a tap opens the fan and the next tap closes it. Fanned, the outer\n * cards stand past the sides of the stack: with the default spread, leave about two thirds of a card\n * of room on each side.\n */\nexport function FanStack({\n  children,\n  label,\n  spread = DEFAULT_SPREAD_DEG,\n  gap = DEFAULT_GAP_PX,\n  isFanned = false,\n  className,\n}: FanStackProps) {\n  const cards = Children.toArray(children);\n  const count = cards.length;\n  const { isHovered, hoverHandlers } = useHover();\n  const [isFocused, setIsFocused] = useState(false);\n  const [isTapped, setIsTapped] = useState(false);\n  const lastPointer = useRef('mouse');\n  const isFannedOut = isFanned || isHovered || isFocused || isTapped;\n\n  function rememberPointer(event: React.PointerEvent<HTMLDivElement>) {\n    lastPointer.current = event.pointerType;\n  }\n\n  function toggleOnTap() {\n    if (lastPointer.current === 'touch') {\n      setIsTapped((current) => !current);\n    }\n  }\n\n  function focusEnters(event: React.FocusEvent<HTMLDivElement>) {\n    const isKeyboardFocus = event.target.matches(':focus-visible');\n    if (isKeyboardFocus) {\n      setIsFocused(true);\n    }\n  }\n\n  function focusLeaves(event: React.FocusEvent<HTMLDivElement>) {\n    const staysInside = event.currentTarget.contains(event.relatedTarget);\n    if (staysInside) {\n      return;\n    }\n    setIsFocused(false);\n    setIsTapped(false);\n  }\n\n  return (\n    <div\n      role='group'\n      aria-label={label}\n      tabIndex={0}\n      onPointerDown={rememberPointer}\n      onClick={toggleOnTap}\n      onFocus={focusEnters}\n      onBlur={focusLeaves}\n      className={cn(\n        'focus-visible:ring-ring focus-visible:ring-offset-background grid w-fit rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-offset-4',\n        className\n      )}\n      {...hoverHandlers}\n    >\n      {cards.map((card, index) => (\n        <div\n          key={keyOf(card, index)}\n          style={poseOf(index, count, isFannedOut, spread, gap)}\n          className='col-start-1 row-start-1 transition-transform duration-500 ease-(--ease-out-strong)'\n        >\n          {card}\n        </div>\n      ))}\n    </div>\n  );\n}\n\ntype Wallpaper = {\n  id: string;\n  name: string;\n  detail: string;\n  picture: string;\n};\n\nfunction WallpaperCard({ wallpaper }: { wallpaper: Wallpaper }) {\n  return (\n    <figure className='bg-card hairline-ring ring-border flex w-32 flex-col gap-2 rounded-xl p-1.5 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'>\n      <span\n        aria-hidden='true'\n        style={{ backgroundImage: `url(${wallpaper.picture})` }}\n        className='block aspect-[4/5] rounded-[calc(var(--radius-xl)-0.375rem)] bg-cover bg-center'\n      />\n      <figcaption className='flex min-w-0 flex-col px-1 pb-0.5'>\n        <span className='truncate text-[13px] font-medium'>\n          {wallpaper.name}\n        </span>\n        <span className='text-muted-foreground truncate text-xs'>\n          {wallpaper.detail}\n        </span>\n      </figcaption>\n    </figure>\n  );\n}\n\nconst WALLPAPERS: Wallpaper[] = [\n  {\n    id: 'dawn',\n    name: 'Dawn',\n    detail: 'Indigo hills',\n    picture: '/images/scenes/scene-01.svg',\n  },\n  {\n    id: 'lagoon',\n    name: 'Lagoon',\n    detail: 'Teal valley',\n    picture: '/images/scenes/scene-02.svg',\n  },\n  {\n    id: 'ember',\n    name: 'Ember',\n    detail: 'Clay ridge',\n    picture: '/images/scenes/scene-03.svg',\n  },\n  {\n    id: 'nightfall',\n    name: 'Nightfall',\n    detail: 'Moonlit ridge',\n    picture: '/images/scenes/scene-07.svg',\n  },\n];\n\nexport default function Effect01() {\n  const [isFanned, setIsFanned] = useState(false);\n  const { isHovered, hoverHandlers } = useHover();\n  const steps: AutoplayStep[] = [\n    { after: 1200, run: () => setIsFanned(true) },\n    { after: 2400, run: () => setIsFanned(false) },\n  ];\n  const { ref, isAutoplaying } = useAutoplay(steps, {\n    loopAfter: 1500,\n    onReset: () => setIsFanned(false),\n    isEnabled: !isHovered,\n  });\n  /** A pointer on the piece only pauses the demonstration, so its fan holds; once a person took over, the fan it left closes. */\n  const isDemoStopped = !isAutoplaying && !isHovered;\n  if (isDemoStopped && isFanned) {\n    setIsFanned(false);\n  }\n\n  return (\n    <div\n      ref={ref}\n      className='flex w-full justify-center py-2'\n      {...hoverHandlers}\n    >\n      <FanStack label='Four wallpapers' isFanned={isFanned}>\n        {WALLPAPERS.map((wallpaper) => (\n          <WallpaperCard key={wallpaper.id} wallpaper={wallpaper} />\n        ))}\n      </FanStack>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/effect/effect-01.tsx"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/rounded.ts",
      "content": "/**\n * Two engines can disagree on the last digit of a sine, and a frame writes many positions: a value\n * written into a style is rounded to the hundredth, so the server and the browser print the same\n * text and the style stays short.\n */\nexport function hundredthsOf(value: number) {\n  const hundredths = Math.round(value * 100);\n  return hundredths / 100;\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/rounded.ts"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/use-hover.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** Whether a mouse or a pen rests on an element. A finger does not hover, so a touch never counts. */\nexport function useHover() {\n  const [isHovered, setIsHovered] = useState(false);\n\n  function hoverStarts(event: React.PointerEvent<HTMLElement>) {\n    if (event.pointerType !== 'touch') {\n      setIsHovered(true);\n    }\n  }\n\n  function hoverEnds() {\n    setIsHovered(false);\n  }\n\n  const hoverHandlers = {\n    onPointerEnter: hoverStarts,\n    onPointerLeave: hoverEnds,\n  };\n\n  return { isHovered, hoverHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/use-hover.ts"
    }
  ],
  "categories": [
    "components",
    "effect"
  ],
  "type": "registry:component"
}