Clickable
The button surface — one component, three exports: `Button`, `Link` and `ExternalLink`. Every clickable affordance in the project composes against it: a dialog's close, an alert-dialog's action pair, a badge's dismiss, a field addon's button.
npx shadcn@latest add @fragiola/clickableShow code
"use client";
import { PlusIcon, Trash2Icon } from "lucide-react";
import type { ReactNode } from "react";
import { Clickable } from "#/atoms/clickable";
// The chromatic palettes — the colour IS the element. Literal here because
// examples are published source (ComponentPreview reads and displays this
// file with a copy button); an import of docs-site chrome would break for
// whoever copies it.
const CHROMATIC = [
"blue",
"purple",
"green",
"orange",
"rose",
"danger",
] as const;
// The palette class goes on the button itself, not on a wrapping context.
// The floor is palette-surface; each button carries its own palette-* class
// and styles only itself. This is the architecture's claim: N palettes can
// coexist in the same subtree, each scoped to the element that needs it.
//
// Axes shown, one per row, all aligned:
// variant solid / outline / ghost / icon
// size sm / md
// disabled solid / outline / ghost / icon
export default function ClickableDemo() {
return (
<div className="palette-surface flex flex-col gap-6 rounded-lg border border-palette-line bg-palette-base p-6">
{/* solid — one button per chromatic palette */}
<Row label="solid">
{CHROMATIC.map((palette) => (
<Clickable.Button
key={palette}
className={`palette-${palette}`}
variant="solid"
size="md"
>
{palette}
</Clickable.Button>
))}
</Row>
{/* outline — one button per chromatic palette */}
<Row label="outline">
{CHROMATIC.map((palette) => (
<Clickable.Button
key={palette}
className={`palette-${palette}`}
variant="outline"
size="md"
>
{palette}
</Clickable.Button>
))}
</Row>
{/* ghost — one button per chromatic palette */}
<Row label="ghost">
{CHROMATIC.map((palette) => (
<Clickable.Button
key={palette}
className={`palette-${palette}`}
variant="ghost"
size="md"
>
{palette}
</Clickable.Button>
))}
</Row>
{/* size axis: sm / md (variant solid, palette blue) */}
<Row label="size">
<Clickable.Button
className="palette-blue"
variant="solid"
size="sm"
>
Small
</Clickable.Button>
<Clickable.Button
className="palette-blue"
variant="solid"
size="md"
>
Medium
</Clickable.Button>
</Row>
{/* icon — variant="icon" + shape="square", sm / md, per palette */}
<Row label="icon">
{CHROMATIC.map((palette) => (
<Clickable.Button
key={palette}
className={`palette-${palette}`}
variant="icon"
shape="square"
size="md"
aria-label={palette}
>
<PlusIcon />
</Clickable.Button>
))}
<Clickable.Button
className="palette-blue"
variant="icon"
shape="square"
size="sm"
aria-label="Small add"
>
<PlusIcon />
</Clickable.Button>
</Row>
{/* disabled — no pointer events, same variants */}
<Row label="disabled">
<Clickable.Button
className="palette-blue"
variant="solid"
size="md"
disabled
>
Solid
</Clickable.Button>
<Clickable.Button
className="palette-blue"
variant="outline"
size="md"
disabled
>
Outline
</Clickable.Button>
<Clickable.Button
className="palette-blue"
variant="ghost"
size="md"
disabled
>
Ghost
</Clickable.Button>
<Clickable.Button
className="palette-danger"
variant="icon"
shape="square"
size="md"
disabled
aria-label="Delete"
>
<Trash2Icon />
</Clickable.Button>
</Row>
</div>
);
}
// Row — a labelled, aligned row of buttons. The label is a <span>, not a
// heading, so it does not pollute the page's table of contents. Items align
// to center and wrap cleanly on narrow viewports.
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-mono text-palette-accent/85">
{label}
</span>
<div className="flex flex-wrap items-center gap-2">{children}</div>
</div>
);
}
Three axes, none of them colour
variant (solid / ghost / outline / icon) is a fill strategy, size (sm / md) is a measure — md is h-control, so a button lines up with a field — and shape (auto / square) is a form. They are orthogonal: size="sm" shape="square" produces a small square button, which is what an addon needs. Folding form into measure is what produces a size="icon" that cannot also be small.
No axis names a colour. A destructive button is <Clickable.Button className="palette-danger" /> — there is no variant="destructive" and no tone prop. This is what keeps destructive from coming back as a hundred copies.
Links and render
Clickable.Link is a plain <a> — it has no dependency on any router. Consumers stitch Next's or TanStack's link through render, the project's single polymorphism mechanism. ExternalLink is a separate export (not a prop) because it differs in defaults and semantics (target, rel), not in appearance.
Parts
| Part | Source |
|---|---|
Button | Base UI Button + clickable() |
Link | useRender (<a>) + clickable() |
ExternalLink | useRender (<a>) + outbound defaults + clickable() |
Text
The primitive every other component points at through `render`. It is what stops a dialog title, a field label and a menu label from each growing their own typography class — seventeen title classes is exactly the duplication this project exists to remove.
Field
One way to write a field, and one input. The box lives on the row; the control is only its middle. The `field` family has ten members and zero variants.