Corner Actions
Buttons

Corner Actions

A corner-docked action cluster that reveals on surface hover or keyboard focus, stays permanently visible on touch, and ships tooltips and 40px targets per action.

Install

npx shadcn@latest add @paragon/corner-actions

Also installs: tooltip

corner-actions.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export type CornerActionsPosition =
  | "top-right"
  | "top-left"
  | "bottom-right"
  | "bottom-left";

const positionClasses: Record<CornerActionsPosition, string> = {
  "top-right": "top-2 right-2",
  "top-left": "top-2 left-2",
  "bottom-right": "bottom-2 right-2",
  "bottom-left": "bottom-2 left-2",
};

const hiddenTranslate: Record<CornerActionsPosition, string> = {
  "top-right": "[@media(hover:hover)and(pointer:fine)]:-translate-y-1",
  "top-left": "[@media(hover:hover)and(pointer:fine)]:-translate-y-1",
  "bottom-right": "[@media(hover:hover)and(pointer:fine)]:translate-y-1",
  "bottom-left": "[@media(hover:hover)and(pointer:fine)]:translate-y-1",
};

export interface CornerActionsAreaProps extends React.ComponentProps<"div"> {}

/**
 * The hoverable surface a `CornerActions` cluster lives on. Purely a named
 * group + positioning context, so any card can opt in without new markup
 * rules.
 */
export function CornerActionsArea({
  className,
  ...props
}: CornerActionsAreaProps) {
  return (
    <div
      data-slot="corner-actions-area"
      className={cn("group/corner relative", className)}
      {...props}
    />
  );
}

export interface CornerActionsProps extends React.ComponentProps<"div"> {
  /** Which corner of the area the cluster docks to. */
  position?: CornerActionsPosition;
  /** Skip the hover reveal and keep the cluster always shown. */
  alwaysVisible?: boolean;
}

/**
 * A docked action cluster that stays out of the way until the surface is
 * hovered or any of its buttons take keyboard focus. On touch devices —
 * where there is no hover to reveal it — the cluster is simply always
 * visible. The reveal is a 150ms fade with a 4px slide from its docking
 * edge; reduced motion keeps only the fade.
 */
export function CornerActions({
  position = "top-right",
  alwaysVisible = false,
  className,
  children,
  ...props
}: CornerActionsProps) {
  return (
    <TooltipProvider>
      <div
        data-slot="corner-actions"
        className={cn(
          "absolute z-10 flex items-center gap-0.5 rounded-lg bg-card/95 p-0.5 shadow-border backdrop-blur-sm",
          positionClasses[position],
          !alwaysVisible && [
            // Hover-capable devices hide the cluster until invited.
            "transition-[opacity,translate] duration-150 ease-(--ease-out)",
            "motion-reduce:translate-y-0 motion-reduce:transition-[opacity]",
            "[@media(hover:hover)and(pointer:fine)]:pointer-events-none [@media(hover:hover)and(pointer:fine)]:opacity-0",
            hiddenTranslate[position],
            "group-hover/corner:pointer-events-auto group-hover/corner:translate-y-0 group-hover/corner:opacity-100",
            "group-focus-within/corner:pointer-events-auto group-focus-within/corner:translate-y-0 group-focus-within/corner:opacity-100",
          ],
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </TooltipProvider>
  );
}

export interface CornerActionProps
  extends Omit<React.ComponentProps<"button">, "aria-label"> {
  /** Accessible name; also the tooltip. */
  label: string;
  /** Tints the hover state toward the destructive token. */
  destructive?: boolean;
  /** Disables press-scale feedback. */
  static?: boolean;
}

/**
 * One icon action inside a `CornerActions` cluster — tooltip and accessible
 * name from a single `label`, 40px hit column, house press physics.
 */
export function CornerAction({
  label,
  destructive = false,
  static: isStatic = false,
  className,
  children,
  ...props
}: CornerActionProps) {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          data-slot="corner-action"
          aria-label={label}
          className={cn(
            "relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none",
            "transition-[color,background-color,scale] duration-150 ease-out",
            "focus-visible:ring-2 focus-visible:ring-ring",
            "disabled:pointer-events-none disabled:opacity-50",
            !isStatic && "pressable",
            destructive
              ? "hover:bg-destructive/10 hover:text-destructive"
              : "hover:bg-accent hover:text-foreground",
            "[&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
            // Vertical-only hit extension so neighbors keep distinct targets.
            "after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
            className,
          )}
          {...props}
        >
          {children}
        </button>
      </TooltipTrigger>
      <TooltipContent>{label}</TooltipContent>
    </Tooltip>
  );
}