Header Icon Button
An icon-only control for bare header chrome, with no fill: a GitHub or social link, a menu toggle.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/header-icon-button.jsonUsage
import { HeaderIconButton } from "@/components/ui/header-icon-button"Examples
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "default" | "lg" | "default" | Box 24 / 32 / 40px, glyph 16 / 20 / 24px. A coarse pointer gets a 44px hit area without the box growing. |
| render | React.ReactElement | - | The element to draw, such as <a href="..." /> or a router <Link />. Defaults to a button. |
| children | React.ReactNode | - | One icon. Its size comes from size, so leave the icon's own size unset. |
| size"sm" | "default" | "lg" = "default"Box 24 / 32 / 40px, glyph 16 / 20 / 24px. A coarse pointer gets a 44px hit area without the box growing. |
renderReact.ReactElementThe element to draw, such as <a href="..." /> or a router <Link />. Defaults to a button. |
childrenReact.ReactNodeOne icon. Its size comes from size, so leave the icon's own size unset. |
"use client"
import * as React from "react"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/registry/lib/utils"
type HeaderIconButtonSize = "sm" | "default" | "lg"
const SIZE: Record<HeaderIconButtonSize, string> = {
sm: "size-6 rounded-action-xs [&_svg]:size-4",
default: "size-8 rounded-action [&_svg]:size-5",
lg: "size-10 rounded-action-lg [&_svg]:size-6",
}
// @use-when an icon-only control in bare header chrome with no fill: a GitHub
// or social link, a menu toggle. Pass `render={<a href="..." />}` for a link.
// Not inside a toolbar or card, where it is a Button with an `icon` size.
function HeaderIconButton({
size = "default",
className,
render,
...props
}: useRender.ComponentProps<"button"> & {
size?: HeaderIconButtonSize
}) {
return useRender({
render: render ?? <button type="button" />,
props: {
"data-slot": "header-icon-button",
className: cn(
"tap-target inline-flex shrink-0 cursor-pointer items-center justify-center text-muted-foreground",
// `transform` is in the list so the press eases like a Button's beside
// it. Reduced motion keeps the colour change, which carries meaning,
// and drops the press entirely.
"transition-[transform,color] duration-small ease-small motion-reduce:transition-[color] hover:text-foreground",
"outline-none focus-visible:ring-ring-focus focus-visible:ring-[3px]",
// 0.98 is the kit's press depth, the same as Button's.
"active:scale-[0.98] motion-reduce:active:scale-100",
SIZE[size],
className
),
...props,
},
})
}
// The box reads the icon-only rungs of the action ramp (24 / 32 / 40), so it
// takes the row height of an icon Button beside it. The glyph does not read
// Button's ramp: Button paints a fill, so its 12 / 14 / 16 sits inside a
// visible pill. With no fill the box is only a hit area and the glyph carries
// the whole read, so it runs ~62% of the box (16 / 20 / 24). Pushing it past
// that was tried and reverted on 2026-09-03: at 80% the mark reads oversized
// rather than as chrome. `tap-target` rather than `pointer-coarse:size-11`
// grows the hit area with a pseudo-element, so a coarse pointer gets 44px
// without the bar reflowing. ThemeToggle is drawn on this part, so the two
// always match in a header.
export { HeaderIconButton, type HeaderIconButtonSize }