{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pressable","type":"registry:ui","title":"Pressable","description":"A wrapper that gives any interactive child a consistent, cross-browser press animation — scale down on pointerdown/keydown, back to rest on release, with a static opacity fallback under reduced motion","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["buttons"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/pressable/Pressable.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type {\n\tCSSProperties,\n\tHTMLAttributes,\n\tKeyboardEvent,\n\tPointerEvent,\n\tReactNode,\n} from \"react\";\nimport { cn } from \"../../utils\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { canVibrate, vibrate } from \"../../internals/motion/haptics\";\nimport type { HapticPattern } from \"../../internals/motion/types\";\nimport \"./pressable.css\";\n\n/**\n * Props for Pressable.\n *\n * Pressable wraps exactly one interactive child (a `<button>`, an `<a>`,\n * or anything that already owns its own role and keyboard handling) and\n * gives it a consistent, cross-browser press animation. It supersedes a\n * hand-rolled `:active` style — see the README before pairing the two.\n */\nexport interface PressableProps\n\textends Omit<\n\t\tHTMLAttributes<HTMLDivElement>,\n\t\t| \"onPointerDown\"\n\t\t| \"onPointerUp\"\n\t\t| \"onPointerCancel\"\n\t\t| \"onPointerLeave\"\n\t\t| \"onBlur\"\n\t\t| \"onKeyDown\"\n\t\t| \"onKeyUp\"\n\t> {\n\t/** Additional CSS classes. */\n\tclassName?: string;\n\t/** How far the wrapper shrinks while pressed, as a CSS `scale()` factor. */\n\tscale?: number;\n\t/**\n\t * Touch-only haptic pattern fired on `pointerdown`. `false` (the\n\t * default) never vibrates — this is an opt-in, additive effect, never\n\t * a required one.\n\t */\n\thaptic?: false | HapticPattern;\n\t/** Suppresses every listener; the wrapper never sets `data-pressed`. */\n\tdisabled?: boolean;\n\t/** Exactly one interactive child. Not enforced at runtime — documented only. */\n\tchildren: ReactNode;\n}\n\nconst DEFAULT_SCALE = 0.97;\n\nfunction isActivationKey(key: string): boolean {\n\treturn key === \" \" || key === \"Enter\";\n}\n\nexport const Pressable = forwardRef<HTMLDivElement, PressableProps>(\n\t(\n\t\t{\n\t\t\tscale = DEFAULT_SCALE,\n\t\t\thaptic = false,\n\t\t\tdisabled = false,\n\t\t\tchildren,\n\t\t\tclassName,\n\t\t\tstyle,\n\t\t\t...restProps\n\t\t},\n\t\tref\n\t) => {\n\t\tconst rootRef = useRef<HTMLDivElement | null>(null);\n\n\t\t// The package composer, not an inline arrow: an arrow declared in the\n\t\t// render body is a new function on every render, and React answers a\n\t\t// changed callback-ref identity by calling the old one with `null` and\n\t\t// the new one with the node. Pressable re-renders on every press,\n\t\t// release and `disabled` flip, so a consumer passing a callback ref\n\t\t// (one that measures the node, or installs an observer) would be torn\n\t\t// down and re-armed twice per press. `useComposedRefs` pins the\n\t\t// identity to the incoming refs, matching the Svelte `bind:this` that\n\t\t// never churns.\n\t\tconst setRootRef = useComposedRefs<HTMLDivElement>(ref, rootRef);\n\n\t\t// Plain state, not a media-query-gated one: the press *state* always\n\t\t// tracks pointer/keyboard activity regardless of `prefers-reduced-motion`.\n\t\t// Only the CSS transition that animates `[data-pressed]` is gated (see\n\t\t// pressable.css) — reduced motion swaps a moving scale for a static\n\t\t// opacity change, it never turns press tracking off.\n\t\tconst [pressed, setPressed] = useState(false);\n\n\t\t// The keydown branch (not the keyup one — see below) only arms on a key\n\t\t// that actually landed inside this wrapper's own subtree. Because the\n\t\t// listener itself lives on `.ft-pressable` and DOM events only reach a\n\t\t// listener via the bubble phase, `event.target` is already guaranteed to\n\t\t// be the root or one of its descendants for every event that gets here —\n\t\t// this check is a second, explicit guarantee of that fact (and the thing\n\t\t// the \"target outside the wrapper\" test asserts against), not a\n\t\t// document-level listener that needs it to survive.\n\t\tfunction isInsideRoot(target: EventTarget | null): boolean {\n\t\t\tconst root = rootRef.current;\n\t\t\treturn !!root && target instanceof Node && root.contains(target);\n\t\t}\n\n\t\tfunction press() {\n\t\t\tif (!disabled) setPressed(true);\n\t\t}\n\n\t\tfunction release() {\n\t\t\tsetPressed(false);\n\t\t}\n\n\t\t// `disabled` can flip true mid-press (a \"Save\" handler disabling its own\n\t\t// button on submit), and the control that just went disabled is not\n\t\t// guaranteed to deliver the pointerup/focusout that would normally\n\t\t// release: Firefox sends no pointerup to a control disabled under the\n\t\t// pointer, and a click that never focused the button (macOS Safari) sends\n\t\t// no focusout either. The CSS `:not([data-disabled])` guard only HIDES the\n\t\t// pressed styling for as long as `disabled` stays true — clearing the\n\t\t// state here is what keeps a later re-enable (an async save resolving)\n\t\t// from revealing a stale `data-pressed` that no live press is behind.\n\t\tuseEffect(() => {\n\t\t\tif (disabled) setPressed(false);\n\t\t}, [disabled]);\n\n\t\tfunction handlePointerDown(event: PointerEvent<HTMLDivElement>) {\n\t\t\tif (disabled) return;\n\t\t\t// Mirrors the drawer precedent elsewhere in this collection: a\n\t\t\t// non-primary mouse button (right-click, middle-click) is not \"a\n\t\t\t// press\". Touch and pen contacts always report `button === 0`, so\n\t\t\t// this only ever filters the mouse case.\n\t\t\tif (event.pointerType === \"mouse\" && event.button !== 0) return;\n\t\t\tpress();\n\t\t\t// Fired inside the same gesture that set `data-pressed`, never\n\t\t\t// deferred — `navigator.vibrate` requires an active user-activation\n\t\t\t// context. Touch only: a stylus press is not a \"tap\" in the sense a\n\t\t\t// haptic buzz confirms, and most styli have no motor of their own to\n\t\t\t// conflate with the device's.\n\t\t\tif (haptic && event.pointerType === \"touch\" && canVibrate()) vibrate(haptic);\n\t\t}\n\n\t\tfunction handlePointerRelease() {\n\t\t\trelease();\n\t\t}\n\n\t\t// React's `onBlur` is attached to the native, bubbling `focusout` event\n\t\t// — this is the same listener the Svelte source registers as\n\t\t// `onfocusout`.\n\t\tfunction handleFocusOut() {\n\t\t\trelease();\n\t\t}\n\n\t\tfunction handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n\t\t\t// OS key-repeat fires `keydown` continuously while a key stays held;\n\t\t\t// without this guard a held Enter/Space would keep \"re-pressing\" at\n\t\t\t// repeat rate, retriggering the haptic-free but still-pointless\n\t\t\t// animation restart on every tick.\n\t\t\tif (disabled || event.repeat) return;\n\t\t\tif (!isActivationKey(event.key)) return;\n\t\t\tif (!isInsideRoot(event.target)) return;\n\t\t\tpress();\n\t\t}\n\n\t\tfunction handleKeyUp(event: KeyboardEvent<HTMLDivElement>) {\n\t\t\tif (event.repeat) return;\n\t\t\tif (!isActivationKey(event.key)) return;\n\t\t\trelease();\n\t\t}\n\n\t\tconst mergedStyle =\n\t\t\tscale === DEFAULT_SCALE\n\t\t\t\t? style\n\t\t\t\t: ({ ...style, \"--ft-pressable-scale\": scale } as CSSProperties);\n\n\t\treturn (\n\t\t\t<div\n\t\t\t\tref={setRootRef}\n\t\t\t\tclassName={cn(\"ft-pressable\", className)}\n\t\t\t\t{...restProps}\n\t\t\t\tstyle={mergedStyle}\n\t\t\t\tdata-pressed={pressed ? \"true\" : undefined}\n\t\t\t\tdata-disabled={disabled ? \"true\" : undefined}\n\t\t\t\tonPointerDown={handlePointerDown}\n\t\t\t\tonPointerUp={handlePointerRelease}\n\t\t\t\tonPointerCancel={handlePointerRelease}\n\t\t\t\tonPointerLeave={handlePointerRelease}\n\t\t\t\tonBlur={handleFocusOut}\n\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t\tonKeyUp={handleKeyUp}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t);\n\t}\n);\n\nPressable.displayName = \"Pressable\";\n","type":"registry:ui","target":"@ui/fancy/components/pressable/Pressable.tsx"},{"path":"react/src/components/pressable/index.ts","content":"\"use client\";\n\nexport { Pressable } from \"./Pressable\";\nexport type { PressableProps } from \"./Pressable\";\n","type":"registry:ui","target":"@ui/fancy/components/pressable/index.ts"},{"path":"react/src/components/pressable/pressable.css","content":"/* Ported verbatim from the Svelte source's <style> block. Every rule was\n   already anchored on the component's root class (`.ft-pressable`), so no\n   port-added anchor was needed. */\n\n.ft-pressable {\n\tdisplay: inline-flex;\n\t/* Removes the ~300ms tap delay and double-tap-zoom on touch without\n\t   blocking pan/scroll the way `touch-action: none` would — a card in\n\t   a scrollable list must stay scrollable while it press-reacts. */\n\ttouch-action: manipulation;\n}\n\n/* Universal fallback, declared ABOVE the `no-preference` block on\n   purpose: press feedback must survive reduced motion (a static opacity\n   change instead of a moving scale), and a UA that supports neither\n   motion-preference query below still needs *some* pressed affordance.\n   Ordinary cascade order — same selector, same specificity, later rule\n   wins — lets the `no-preference` block below override this back to\n   `opacity: 1` when motion is enabled, with no `!important` and no\n   duplicated guard.\n\n   `:not([data-disabled])`: belt-and-braces for the mid-press disable case\n   the disabled-flip effect in the component actually handles (it clears\n   `pressed` itself, so `data-pressed` is gone by the time that flush\n   paints) — this keeps a disabled wrapper from ever painting the pressed\n   styling, whatever order a UA delivers the events in. */\n.ft-pressable[data-pressed]:not([data-disabled]) {\n\topacity: var(--ft-pressable-opacity, 0.85);\n}\n\n/* The scale itself, and the transition that animates it, both live inside\n   `no-preference` — reduced motion drops the moving part entirely rather\n   than playing it at zero duration, matching this collection's rule that\n   the resting state (here: `scale(1)`, unanimated) is the fallback.\n\n   The pressed rule also resets `opacity` back to `1` here: without it, a\n   no-preference press would both scale down AND fade via the fallback\n   rule above — two channels for one interaction, and the two would\n   desync on release since only the scale is transitioned. `opacity` is\n   deliberately NOT added to the transition list below — that would only\n   smooth the now-pointless fade rather than remove the second channel,\n   and R5 (\"one idea per interaction\") wants scale to be the only thing\n   that moves while motion is enabled. */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-pressable {\n\t\ttransform: scale(1);\n\t\t/* Literal fallbacks: 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) = tokens.EASINGS.inout */\n\t\ttransition: transform var(--ft-pressable-duration, var(--ft-duration-fast, 150ms))\n\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t}\n\n\t.ft-pressable[data-pressed]:not([data-disabled]) {\n\t\ttransform: scale(var(--ft-pressable-scale, 0.97));\n\t\topacity: 1;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/pressable/pressable.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/pressable?framework=react"}}