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 · lightclass-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 }