shadimage
whiskeyjack
uiaction-pillsource: ok · refreshed

ActionPill

Toolbar pill of icon actions that collapse and grow as the page's context changes.

npx shadcn add "https://whiskeyjack.net/r/action-pill.json"
free

Interaction

accordion

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

Dependencies

Bundle weight · light
class-variance-authority

Registry dependencies

https://whiskeyjack.net/r/utils.json

Files1 · source cached 10/8/2026, 6:18:13 AM

components/ui/action-pill.tsx
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

// ------------------------------------------------------------------
// ActionPill container
// ------------------------------------------------------------------

const actionPillVariants = cva(
  [
    'flex items-center rounded-full',
    'shadow-sm overflow-hidden transition-all duration-300',
  ],
  {
    variants: {
      size: {
        // Desktop rides the shared control scale so a pill sits level with a
        // Button beside it; mobile stays a thumb-sized bar of its own.
        desktop: 'h-control-md',
        mobile: 'h-12',
      },
      variant: {
        accent:
          'bg-[var(--color-accent-500)] text-[var(--color-accent-foreground-icon)] shadow-sm-bevel-accent',
        surface: [
          'wj-frost',
          'border border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]',
          'text-[var(--color-accent-500)]',
        ].join(' '),
      },
    },
    defaultVariants: {
      size: 'desktop',
      variant: 'accent',
    },
  }
)

export type ActionPillVariant = 'accent' | 'surface'

export interface ActionPillProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof actionPillVariants> {}

/**
 * Pill container for icon-only toolbar action buttons. Place one or more
 * `ActionPillButton` children inside.
 *
 * Size variants: `desktop` (the shared 42px control height) and `mobile`
 * (h-12 / 48px, buttons w-12).
 *
 * Visual variants: `accent` (the primary pill -- accent bg, foreground-icon
 * color) and `surface` (secondary pill -- frosted surface bg + border with
 * accent-colored icons, same chrome as BottomNav / elevated toolbar panes).
 * Use `accent` for THE primary action group and `surface` for secondary
 * groups sitting next to it, so adjacent pills read as distinct groups
 * rather than one blob. Pass the same `variant` to the buttons inside.
 */
const ActionPill = React.forwardRef<HTMLDivElement, ActionPillProps>(
  ({ className, size, variant, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(actionPillVariants({ size, variant }), className)}
      {...props}
    />
  )
)
ActionPill.displayName = 'ActionPill'

// ------------------------------------------------------------------
// Shared button hover/active treatment per pill variant
// ------------------------------------------------------------------

const buttonVariantClasses: Record<ActionPillVariant, string> = {
  accent:
    'hover:bg-[var(--color-accent-foreground-icon-10)] active:bg-[var(--color-accent-foreground-icon-20)]',
  surface:
    'hover:bg-[var(--color-warm-100)] dark:hover:bg-[var(--color-neutral-800)] active:bg-[var(--color-warm-200)] dark:active:bg-[var(--color-neutral-700)] disabled:opacity-50 disabled:pointer-events-none',
}

// ------------------------------------------------------------------
// ActionPillButton
// ------------------------------------------------------------------

export interface ActionPillButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** When false the button collapses to w-0 and becomes inert. */
  visible?: boolean
  /**
   * ReactNode icon to render. The DS avoids bundling an icon library so the
   * caller passes the icon element directly (e.g. `<Plus size={20} weight="bold" />`).
   */
  icon: React.ReactNode
  /** aria-label and title for the button -- required for accessibility. */
  label: string
  /** Size variant inherited from the parent ActionPill (pass through explicitly). */
  size?: 'desktop' | 'mobile'
  /** Visual variant inherited from the parent ActionPill (pass through explicitly). */
  variant?: ActionPillVariant
}

/**
 * Icon-only button for use inside `ActionPill`.
 *
 * When `visible` is `false` the button collapses to zero width, opacity 0, and
 * `pointer-events-none` with `tabIndex=-1` so it is completely inert. This is
 * the morphing-pill pattern: the pill container shrinks/grows as context-
 * dependent buttons are shown or hidden.
 *
 * `label` is applied as both `aria-label` and `title` (tooltip on hover).
 */
const ActionPillButton = React.forwardRef<HTMLButtonElement, ActionPillButtonProps>(
  ({ visible = true, icon, label, size = 'desktop', variant = 'accent', className, ...props }, ref) => {
    const dim = size === 'mobile' ? 'w-12 h-12' : 'w-control-md h-control-md'
    return (
      <button
        ref={ref}
        aria-label={label}
        title={label}
        tabIndex={visible ? 0 : -1}
        className={cn(
          'wj-focus-ring flex items-center justify-center',
          buttonVariantClasses[variant],
          'transition-all',
          // Asymmetric on purpose. A button that is LEAVING is never the one
          // being aimed at, so the collapse keeps the full 300ms and reads as
          // the pill closing up. A button ARRIVING is exactly what gets
          // clicked, and while its width animates its hit box is narrower
          // than where it will land -- a click aimed at the final position
          // falls through to the page and is lost. Expanding in half the time
          // puts the target under the pointer before a hand can reach it.
          visible
            ? `${dim} opacity-100 duration-150`
            : 'w-0 opacity-0 pointer-events-none duration-300',
          className
        )}
        {...props}
      >
        {icon}
      </button>
    )
  }
)
ActionPillButton.displayName = 'ActionPillButton'

// ------------------------------------------------------------------
// ActionPillStaticButton -- always-visible button (no collapse)
// ------------------------------------------------------------------

export interface ActionPillStaticButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  icon: React.ReactNode
  label: string
  size?: 'desktop' | 'mobile'
  variant?: ActionPillVariant
}

/**
 * Always-visible button for use inside `ActionPill`. Use for actions that are
 * never context-dependent (e.g., the "+" create button). Contrast with
 * `ActionPillButton` which can be hidden.
 */
const ActionPillStaticButton = React.forwardRef<HTMLButtonElement, ActionPillStaticButtonProps>(
  ({ icon, label, size = 'desktop', variant = 'accent', className, ...props }, ref) => {
    const dim = size === 'mobile' ? 'w-12 h-12' : 'w-control-md h-control-md'
    return (
      <button
        ref={ref}
        aria-label={label}
        title={label}
        className={cn(
          'wj-focus-ring flex items-center justify-center',
          dim,
          buttonVariantClasses[variant],
          'transition-colors',
          className
        )}
        {...props}
      >
        {icon}
      </button>
    )
  }
)
ActionPillStaticButton.displayName = 'ActionPillStaticButton'

export { ActionPill, ActionPillButton, ActionPillStaticButton, actionPillVariants }

More from whiskeyjack

Browse all components in this library →