shadimage
joyco
componentaction-hintsource: ok · hit

Action Hint

A particle emitter for showing ephemeral action feedback hints that fade out.

npx shadcn add "https://registry.joyco.studio/r/action-hint.json"
free

Where it goes

blog-list

Visual style

particle

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

@joyco/utils

Files1 · source cached 10/8/2026, 8:30:48 PM

registry/components/action-hint.tsx
'use client'

import * as React from 'react'
import { cn } from '@/lib/utils'

/* -------------------------------------------------------------------------------------------------
 * Types
 * -----------------------------------------------------------------------------------------------*/

type Align = 'start' | 'center' | 'end'

type Particle = {
  id: number
  content: React.ReactNode
}

type ActionHintContextValue = {
  emit: (content: React.ReactNode) => void
}

/* -------------------------------------------------------------------------------------------------
 * Context
 * -----------------------------------------------------------------------------------------------*/

const ActionHintContext = React.createContext<ActionHintContextValue | null>(
  null
)

export function useActionHint() {
  const context = React.useContext(ActionHintContext)
  if (!context) {
    throw new Error('useActionHint must be used within an ActionHintEmitter')
  }
  return context
}

/* -------------------------------------------------------------------------------------------------
 * ActionHintEmitter
 * -----------------------------------------------------------------------------------------------*/

interface ActionHintEmitterProps {
  children: React.ReactNode
  className?: string
  /** Horizontal alignment of the hint relative to the emitter */
  align?: Align
  /** Vertical margin (gap) between the hint and the emitter in pixels */
  margin?: number
  /** Duration of the fade animation in milliseconds */
  duration?: number
}

export const ActionHintEmitter = React.forwardRef<
  HTMLDivElement,
  ActionHintEmitterProps
>(function ActionHintEmitter(
  { children, className, align = 'center', margin = 4, duration = 1500 },
  forwardedRef
) {
  const [particles, setParticles] = React.useState<Particle[]>([])
  const idRef = React.useRef(0)

  const emit = React.useCallback(
    (content: React.ReactNode) => {
      const id = idRef.current++

      // Clear all existing particles and add new one
      setParticles([{ id, content }])

      // Remove after animation completes
      setTimeout(() => {
        setParticles((prev) => prev.filter((p) => p.id !== id))
      }, duration)
    },
    [duration]
  )

  const contextValue = React.useMemo(() => ({ emit }), [emit])

  return (
    <ActionHintContext.Provider value={contextValue}>
      <div ref={forwardedRef} className={cn('relative inline-flex', className)}>
        {children}
        {particles.map((particle) => (
          <ActionHintParticle
            key={particle.id}
            align={align}
            margin={margin}
            duration={duration}
          >
            {particle.content}
          </ActionHintParticle>
        ))}
      </div>
    </ActionHintContext.Provider>
  )
})

/* -------------------------------------------------------------------------------------------------
 * ActionHintParticle
 * -----------------------------------------------------------------------------------------------*/

const alignToPosition: Record<
  Align,
  { left?: string; right?: string; transform: string }
> = {
  start: { left: '0', transform: 'translateY(-100%)' },
  center: { left: '50%', transform: 'translateX(-50%) translateY(-100%)' },
  end: { right: '0', transform: 'translateY(-100%)' },
}

function ActionHintParticle({
  children,
  align,
  margin,
  duration,
}: {
  children: React.ReactNode
  align: Align
  margin: number
  duration: number
}) {
  const positionStyles = alignToPosition[align]

  return (
    <>
      <style>
        {`
          @keyframes action-hint-fade {
            0% {
              opacity: 1;
              margin-top: var(--action-hint-margin);
            }
            100% {
              opacity: 0;
              margin-top: calc(var(--action-hint-margin) - 1rem);
            }
          }
        `}
      </style>
      <div
        data-slot="action-hint-particle"
        className={cn(
          'pointer-events-none absolute top-0 z-50',
          'rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap shadow-sm',
          'bg-secondary text-secondary-foreground'
        )}
        style={
          {
            ...positionStyles,
            '--action-hint-margin': `${-margin}px`,
            animation: `action-hint-fade ${duration}ms ease-out forwards`,
          } as React.CSSProperties
        }
      >
        {children}
      </div>
    </>
  )
}

More from joyco

Browse all components in this library →