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.
npx shadcn@latest add @paragon/corner-actionsAlso installs: tooltip
"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>
);
}