{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button-66",
  "title": "Buttons with living icons",
  "description": "Secondary, outline, destructive and soft status buttons whose icon acts out its verb when the hand reaches it and when it is pressed: the arrow leaves and comes back, the bin lifts its lid, the bell swings once and the download drops.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/button/button-66.tsx",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button, type ButtonProps } from '@/registry/ndeko/ui/button';\n\nimport { GLYPHS, type LivingIconName } from './_parts/living-glyphs';\nimport { useHover } from './_parts/use-hover';\nimport { usePose, type Pose } from './_parts/use-pose';\n\nexport type LivingTone =\n  'secondary' | 'outline' | 'destructive' | 'info' | 'success' | 'warning';\n\ntype ToneRecipe = {\n  variant: ButtonProps['variant'];\n  className?: string;\n};\n\nconst TONES: Record<LivingTone, ToneRecipe> = {\n  secondary: { variant: 'secondary' },\n  outline: { variant: 'outline' },\n  destructive: { variant: 'destructive' },\n  info: {\n    variant: 'secondary',\n    className: 'bg-info/10 text-info-foreground hover:bg-info/15',\n  },\n  success: {\n    variant: 'secondary',\n    className: 'bg-success/10 text-success-foreground hover:bg-success/15',\n  },\n  warning: {\n    variant: 'secondary',\n    className: 'bg-warning/10 text-warning-foreground hover:bg-warning/15',\n  },\n};\n\n/** A demonstration presses deeper than a finger does, so the press reads from across the page. */\nconst DEMO_PRESSED = 'scale-[0.94]';\n\nfunction demoPressOf(forcedPose: Pose | undefined) {\n  if (forcedPose === 'press') {\n    return DEMO_PRESSED;\n  }\n  return undefined;\n}\n\nexport type LivingButtonProps = Omit<ButtonProps, 'variant'> & {\n  /** The icon that answers the hand: the arrow leaves and returns, the bin lifts its lid, the bell swings once, the download drops. */\n  icon: LivingIconName;\n  tone?: LivingTone;\n  /** Holds the icon in a pose, for a demonstration that plays it without a pointer. */\n  pose?: Pose;\n};\n\n/**\n * A button whose icon acts out its verb when the hand reaches it and when it is pressed, once and\n * never in a loop. The keyboard gets the same answers as the pointer. The arrow follows the label;\n * the other icons come before it. Under reduced motion the arrow and the bell keep still, and the\n * bin and the download change pose without travelling there.\n */\nexport function LivingButton({\n  icon,\n  tone = 'secondary',\n  pose: forcedPose,\n  className,\n  children,\n  ...props\n}: LivingButtonProps) {\n  const { pose: livePose, poseHandlers } = usePose<HTMLSpanElement>();\n  const shownPose = forcedPose ?? livePose;\n  const recipe = TONES[tone];\n  const Glyph = GLYPHS[icon];\n  const followsLabel = icon === 'arrow';\n\n  return (\n    <span className='contents' {...poseHandlers}>\n      <Button\n        variant={recipe.variant}\n        className={cn(recipe.className, demoPressOf(forcedPose), className)}\n        {...props}\n      >\n        {!followsLabel && <Glyph pose={shownPose} />}\n        {children}\n        {followsLabel && <Glyph pose={shownPose} />}\n      </Button>\n    </span>\n  );\n}\n\ntype DemoButton = 'continue' | 'download' | 'delete' | 'remind';\n\ntype Forced = {\n  button: DemoButton;\n  pose: Pose;\n};\n\ntype DemoBeat = Forced & {\n  after: number;\n};\n\ntype ButtonSpec = {\n  id: DemoButton;\n  icon: LivingIconName;\n  tone: LivingTone;\n  label: string;\n};\n\nconst BUTTONS: ButtonSpec[] = [\n  { id: 'continue', icon: 'arrow', tone: 'secondary', label: 'Continue' },\n  { id: 'download', icon: 'download', tone: 'outline', label: 'Download' },\n  { id: 'delete', icon: 'bin', tone: 'destructive', label: 'Delete' },\n  { id: 'remind', icon: 'bell', tone: 'info', label: 'Remind me' },\n];\n\n/** Each button is reached, pressed and let go in turn. The bin is reached again after the press, as a hand that stays on it would. */\nconst BEATS: DemoBeat[] = [\n  { after: 1100, button: 'continue', pose: 'hover' },\n  { after: 1200, button: 'continue', pose: 'press' },\n  { after: 380, button: 'continue', pose: 'rest' },\n  { after: 700, button: 'download', pose: 'hover' },\n  { after: 1100, button: 'download', pose: 'press' },\n  { after: 380, button: 'download', pose: 'rest' },\n  { after: 700, button: 'delete', pose: 'hover' },\n  { after: 1300, button: 'delete', pose: 'press' },\n  { after: 420, button: 'delete', pose: 'hover' },\n  { after: 700, button: 'delete', pose: 'rest' },\n  { after: 700, button: 'remind', pose: 'hover' },\n  { after: 1300, button: 'remind', pose: 'press' },\n  { after: 700, button: 'remind', pose: 'rest' },\n];\n\nfunction forcedWhile(isAutoplaying: boolean, forced: Forced | null) {\n  if (isAutoplaying) {\n    return forced;\n  }\n  return null;\n}\n\nfunction poseOfButton(forced: Forced | null, id: DemoButton) {\n  if (forced === null) {\n    return undefined;\n  }\n  if (forced.button !== id) {\n    return undefined;\n  }\n  return forced.pose;\n}\n\nexport default function Button66() {\n  const [forced, setForced] = useState<Forced | null>(null);\n  const { isHovered, hoverHandlers } = useHover();\n  const steps: AutoplayStep[] = BEATS.map((beat) => ({\n    after: beat.after,\n    run: () => setForced({ button: beat.button, pose: beat.pose }),\n  }));\n  const { ref, isAutoplaying } = useAutoplay(steps, {\n    loopAfter: 1500,\n    onReset: () => setForced(null),\n    isEnabled: !isHovered,\n  });\n  const shown = forcedWhile(isAutoplaying, forced);\n\n  return (\n    <div\n      ref={ref}\n      className='@container w-full max-w-[34rem]'\n      {...hoverHandlers}\n    >\n      <div className='grid grid-cols-2 justify-items-center gap-3 @lg:flex @lg:flex-wrap @lg:justify-center'>\n        {BUTTONS.map((spec) => (\n          <LivingButton\n            key={spec.id}\n            icon={spec.icon}\n            tone={spec.tone}\n            pose={poseOfButton(shown, spec.id)}\n          >\n            {spec.label}\n          </LivingButton>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/button/button-66.tsx"
    },
    {
      "path": "registry/ndeko/components/button/_parts/living-glyphs.tsx",
      "content": "'use client';\n\nimport { useEffect, useRef } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { ArrowRightIcon, BellIcon } from '@/registry/ndeko/lib/icons';\n\nimport type { Pose } from './use-pose';\n\nexport type LivingIconName = 'arrow' | 'download' | 'bin' | 'bell';\n\nexport type GlyphProps = {\n  pose: Pose;\n};\n\ntype Act = {\n  keyframes: Keyframe[];\n  options: KeyframeAnimationOptions;\n};\n\ntype Acts = {\n  hover: Act;\n  press: Act;\n};\n\nconst OUT_STRONG = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\nconst IN_OUT_STRONG = 'cubic-bezier(0.77, 0, 0.175, 1)';\n\nconst SWING_OFFSETS = [0, 0.16, 0.42, 0.64, 0.82, 1];\n\nconst SWING_REACH = [0, 1, -0.75, 0.45, -0.2, 0];\n\n/** The bell hangs from its top, a little below the top of its box. */\nconst BELL_HINGE = '50% 8%';\n\n/** The lid turns about its left end, which is where the bin's hinge would be. */\nconst LID_HINGE = '4px 7.5px';\n\nconst POSE_MOTION =\n  'transition-transform duration-200 ease-(--ease-out-strong)';\n\n/** The arrow leaves through the right edge of its slot and comes back from the left edge, with a short beat of an empty slot between. */\nconst ARROW_ROLL: Keyframe[] = [\n  {\n    offset: 0,\n    transform: 'translateX(0%)',\n    opacity: 1,\n    easing: IN_OUT_STRONG,\n  },\n  { offset: 0.4, transform: 'translateX(130%)', opacity: 0 },\n  {\n    offset: 0.46,\n    transform: 'translateX(-130%)',\n    opacity: 0,\n    easing: OUT_STRONG,\n  },\n  { offset: 1, transform: 'translateX(0%)', opacity: 1 },\n];\n\nconst ARROW_POKE: Keyframe[] = [\n  { offset: 0, transform: 'translateX(0px)' },\n  { offset: 0.4, transform: 'translateX(2.5px)' },\n  { offset: 1, transform: 'translateX(0px)' },\n];\n\nfunction swingKeyframesOf(amplitude: number): Keyframe[] {\n  return SWING_OFFSETS.map((offset, step) => {\n    const angle = amplitude * SWING_REACH[step];\n    return { offset, transform: `rotate(${angle}deg)`, easing: 'ease-in-out' };\n  });\n}\n\nconst ARROW_ACTS: Acts = {\n  hover: { keyframes: ARROW_ROLL, options: { duration: 560 } },\n  press: {\n    keyframes: ARROW_POKE,\n    options: { duration: 280, easing: OUT_STRONG },\n  },\n};\n\nconst BELL_ACTS: Acts = {\n  hover: { keyframes: swingKeyframesOf(14), options: { duration: 800 } },\n  press: { keyframes: swingKeyframesOf(20), options: { duration: 520 } },\n};\n\nconst ARROW_DROP: Record<Pose, string> = {\n  rest: 'translateY(0px)',\n  hover: 'translateY(2.4px)',\n  press: 'translateY(3.6px)',\n};\n\nconst LID_TURN: Record<Pose, string> = {\n  rest: 'translateY(0px) rotate(0deg)',\n  hover: 'translateY(-1.5px) rotate(-12deg)',\n  press: 'translateY(0.9px) rotate(0deg)',\n};\n\n/**\n * Plays an act on the element it returns when the pose changes: the hover act when a control is\n * reached from rest, the press act whenever it is pressed. Nothing plays under reduced motion.\n */\nfunction useActs<Element extends HTMLElement>(pose: Pose, acts: Acts) {\n  const ref = useRef<Element>(null);\n  const before = useRef<Pose>('rest');\n  const isStill = usePrefersReducedMotion();\n\n  useEffect(() => {\n    const previous = before.current;\n    before.current = pose;\n    const element = ref.current;\n    const hasChanged = previous !== pose;\n    if (!element) {\n      return;\n    }\n    if (!hasChanged || isStill) {\n      return;\n    }\n    if (pose === 'press') {\n      element.animate(acts.press.keyframes, acts.press.options);\n      return;\n    }\n    const startsHover = pose === 'hover' && previous === 'rest';\n    if (startsHover) {\n      element.animate(acts.hover.keyframes, acts.hover.options);\n    }\n  }, [pose, acts, isStill]);\n\n  return ref;\n}\n\nfunction ArrowGlyph({ pose }: GlyphProps) {\n  const ref = useActs<HTMLSpanElement>(pose, ARROW_ACTS);\n\n  return (\n    <span\n      aria-hidden='true'\n      className='flex size-4 shrink-0 items-center justify-center overflow-hidden'\n    >\n      <span ref={ref} className='flex'>\n        <ArrowRightIcon />\n      </span>\n    </span>\n  );\n}\n\nfunction BellGlyph({ pose }: GlyphProps) {\n  const ref = useActs<HTMLSpanElement>(pose, BELL_ACTS);\n\n  return (\n    <span\n      ref={ref}\n      aria-hidden='true'\n      style={{ transformOrigin: BELL_HINGE }}\n      className='flex shrink-0'\n    >\n      <BellIcon />\n    </span>\n  );\n}\n\n/** The module's tray and arrow cannot move apart, so this one draws them as two parts on the same grid and stroke. */\nfunction DownloadGlyph({ pose }: GlyphProps) {\n  return (\n    <svg\n      viewBox='0 0 24 24'\n      fill='none'\n      stroke='currentColor'\n      strokeWidth={1.75}\n      strokeLinecap='round'\n      strokeLinejoin='round'\n      aria-hidden='true'\n      className='size-4'\n    >\n      <g style={{ transform: ARROW_DROP[pose] }} className={POSE_MOTION}>\n        <path d='M12 3.5v11' />\n        <path d='m7.5 10 4.5 4.5 4.5-4.5' />\n      </g>\n      <path d='M4.5 15.5v2.2a2.3 2.3 0 0 0 2.3 2.3h10.4a2.3 2.3 0 0 0 2.3-2.3v-2.2' />\n    </svg>\n  );\n}\n\n/** The module's bin draws its lid and its body as one shape, so the lid cannot lift; this one draws them apart. */\nfunction BinGlyph({ pose }: GlyphProps) {\n  return (\n    <svg\n      viewBox='0 0 24 24'\n      fill='none'\n      stroke='currentColor'\n      strokeWidth={1.75}\n      strokeLinecap='round'\n      strokeLinejoin='round'\n      aria-hidden='true'\n      className='size-4'\n    >\n      <g\n        style={{ transform: LID_TURN[pose], transformOrigin: LID_HINGE }}\n        className={POSE_MOTION}\n      >\n        <path d='M4 7.5h16' />\n        <path d='M9.5 7.5V6.1A1.1 1.1 0 0 1 10.6 5h2.8a1.1 1.1 0 0 1 1.1 1.1v1.4' />\n      </g>\n      <path d='M6 7.5l.85 11.1a2 2 0 0 0 2 1.9h6.3a2 2 0 0 0 2-1.9L18 7.5' />\n      <path d='M10.2 11.5v5M13.8 11.5v5' />\n    </svg>\n  );\n}\n\nexport const GLYPHS: Record<LivingIconName, React.ComponentType<GlyphProps>> = {\n  arrow: ArrowGlyph,\n  download: DownloadGlyph,\n  bin: BinGlyph,\n  bell: BellGlyph,\n};\n",
      "type": "registry:component",
      "target": "components/button/_parts/living-glyphs.tsx"
    },
    {
      "path": "registry/ndeko/components/button/_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/button/_parts/use-hover.ts"
    },
    {
      "path": "registry/ndeko/components/button/_parts/use-pose.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nexport type Pose = 'rest' | 'hover' | 'press';\n\nconst PRESS_KEYS = [' ', 'Enter'];\n\nfunction poseOf(isPressed: boolean, isReached: boolean): Pose {\n  if (isPressed) {\n    return 'press';\n  }\n  if (isReached) {\n    return 'hover';\n  }\n  return 'rest';\n}\n\nfunction isPressKey(key: string) {\n  return PRESS_KEYS.includes(key);\n}\n\n/**\n * What a control is being offered. A mouse or a pen on it, or the keyboard on it, is a hover; a\n * press of the pointer or of Space or Enter is a press. A finger never hovers, so a touch only\n * presses. Spread the handlers on an ancestor of the control: the events reach it from inside.\n */\nexport function usePose<Element extends HTMLElement>() {\n  const [isHovered, setIsHovered] = useState(false);\n  const [isFocused, setIsFocused] = useState(false);\n  const [isPressed, setIsPressed] = useState(false);\n  const isReached = isHovered || isFocused;\n  const pose = poseOf(isPressed, isReached);\n\n  function hoverStarts(event: React.PointerEvent<Element>) {\n    if (event.pointerType !== 'touch') {\n      setIsHovered(true);\n    }\n  }\n\n  function hoverEnds() {\n    setIsHovered(false);\n    setIsPressed(false);\n  }\n\n  function pointerPresses() {\n    setIsPressed(true);\n  }\n\n  function pointerReleases() {\n    setIsPressed(false);\n  }\n\n  function focusStarts(event: React.FocusEvent<Element>) {\n    const isKeyboardFocus = event.target.matches(':focus-visible');\n    if (isKeyboardFocus) {\n      setIsFocused(true);\n    }\n  }\n\n  function focusEnds() {\n    setIsFocused(false);\n    setIsPressed(false);\n  }\n\n  function keyPresses(event: React.KeyboardEvent<Element>) {\n    if (isPressKey(event.key)) {\n      setIsPressed(true);\n    }\n  }\n\n  function keyReleases(event: React.KeyboardEvent<Element>) {\n    if (isPressKey(event.key)) {\n      setIsPressed(false);\n    }\n  }\n\n  const poseHandlers = {\n    onPointerEnter: hoverStarts,\n    onPointerLeave: hoverEnds,\n    onPointerDown: pointerPresses,\n    onPointerUp: pointerReleases,\n    onPointerCancel: pointerReleases,\n    onFocus: focusStarts,\n    onBlur: focusEnds,\n    onKeyDown: keyPresses,\n    onKeyUp: keyReleases,\n  };\n\n  return { pose, poseHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/button/_parts/use-pose.ts"
    }
  ],
  "categories": [
    "components",
    "button"
  ],
  "type": "registry:component"
}