Icon-Knopf
Knöpfe, die nur ein Icon sind, mit Pflicht-Label, vier Größen, fünf Stilen und Zähler.
Eine von 6 Components in Buttons
Details
Was Icon-Knopf mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Buttons
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/icon-button.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 170 Zeilen
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
export type IconButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'soft';
export type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
const VARIANT: Record<IconButtonVariant, string> = {
primary: 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]',
secondary: 'bg-secondary text-secondary-foreground shadow-[var(--highlight)] hover:bg-accent',
outline: 'border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] hover:bg-accent',
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground aria-pressed:bg-accent aria-pressed:text-foreground',
soft: 'bg-[color-mix(in_oklab,var(--primary)_11%,transparent)] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_17%,transparent)]',
};
/* Small sizes keep a 44 px hit area through an invisible ::after */
const SIZE: Record<IconButtonSize, { box: string; icon: number }> = {
xs: { box: 'size-7 rounded-md after:-inset-2', icon: 15 },
sm: { box: 'size-9 rounded-md after:-inset-1', icon: 17 },
md: { box: 'size-11 rounded-lg', icon: 19 },
lg: { box: 'size-12 rounded-lg', icon: 20 },
};
export function iconButtonClass({ variant = 'ghost', size = 'md', round, className }: { variant?: IconButtonVariant; size?: IconButtonSize; round?: boolean; className?: string }) {
return cn(
'group/icon-button relative isolate inline-grid shrink-0 select-none place-items-center transition-[background-color,color,box-shadow,transform,scale] duration-200 ease-[var(--ease-kit)] after:absolute after:content-[""] active:scale-[0.92] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
SIZE[size].box,
VARIANT[variant],
round && 'rounded-full',
className,
);
}
/**
* A quiet ring that spreads from the press point and fades, behind the icon.
* Pass as onPointerDown on any element styled with iconButtonClass.
*/
export function iconPress(e: React.PointerEvent<HTMLElement> | React.MouseEvent<HTMLElement>) {
const el = e.currentTarget;
if (prefersReducedMotion() || typeof el.animate !== 'function') return;
if ('button' in e && e.button !== 0) return;
const r = el.getBoundingClientRect();
// A keyboard press (click with detail 0) starts in the middle
const keyboard = e.type === 'click';
const x = keyboard ? r.width / 2 : e.clientX - r.left;
const y = keyboard ? r.height / 2 : e.clientY - r.top;
const size = Math.ceil(Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y)) * 2);
const clip = document.createElement('span');
clip.setAttribute('aria-hidden', 'true');
clip.style.cssText = 'position:absolute;inset:0;z-index:-1;border-radius:inherit;overflow:hidden;pointer-events:none;';
const dot = document.createElement('span');
dot.style.cssText = `position:absolute;left:${Math.round(x - size / 2)}px;top:${Math.round(y - size / 2)}px;width:${size}px;height:${size}px;border-radius:9999px;background:color-mix(in oklab, currentColor 16%, transparent);`;
clip.append(dot);
el.append(clip);
const anim = dot.animate([{ transform: 'scale(0.1)', opacity: 1 }, { transform: 'scale(1)', opacity: 0 }], { duration: 520, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
anim.onfinish = anim.oncancel = () => clip.remove();
}
/** A spring pop: small, a little too big, then at rest */
const POP: Keyframe[] = [
{ transform: 'scale(0.3)', opacity: 0 },
{ transform: 'scale(1.18)', opacity: 1, offset: 0.55 },
{ transform: 'scale(0.96)', offset: 0.8 },
{ transform: 'scale(1)', opacity: 1 },
];
/** Pops once when it first comes into view and again whenever the count changes */
function Badge({ badge, size }: { badge: number | boolean; size: IconButtonSize }) {
const ref = useRef<HTMLSpanElement>(null);
const last = useRef(badge);
const pop = () => {
const el = ref.current;
if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate(POP, { duration: 520, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
};
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(([e]) => {
if (!e.isIntersecting) return;
io.disconnect();
pop();
});
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
if (last.current === badge) return;
last.current = badge;
pop();
}, [badge]);
if (badge === false || badge === 0) return null;
if (badge === true)
return <span ref={ref} aria-hidden className={cn('absolute rounded-full bg-primary ring-2 ring-background', size === 'xs' || size === 'sm' ? 'right-1.5 top-1.5 size-2' : 'right-2 top-2 size-2.5')} />;
return (
<span ref={ref} aria-hidden className="absolute -right-1 -top-1 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-primary px-1 text-[10.5px] font-semibold tabular-nums leading-none text-primary-foreground ring-2 ring-background">
{badge > 99 ? '99+' : badge}
</span>
);
}
interface Common {
icon: IconName;
/** Required: the accessible name, also used as the native title when no tooltip wraps it */
label: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
/** circle instead of the rounded square */
round?: boolean;
/** A count or a dot in the corner, e.g. unread messages. true shows a dot. Pops in and on every change. */
badge?: number | boolean;
className?: string;
}
type AsButton = Common & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof Common | 'children'> & { href?: undefined; ref?: React.Ref<HTMLButtonElement> };
type AsLink = Common & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common | 'children'> & { href: string; ref?: React.Ref<HTMLAnchorElement> };
/**
* A button that is only an icon. The label is required and becomes the accessible name.
* A press scales it down and sends a quiet ring out from the press point.
* <IconButton icon="bell" label="Notifications" badge={3} />
*/
export function IconButton(props: AsButton | AsLink) {
const { icon, label, variant, size = 'md', round, badge, className, ...rest } = props;
const classes = iconButtonClass({ variant, size, round, className });
const name = typeof badge === 'number' && badge > 0 ? `${label} (${badge})` : label;
const inner = (
<>
<Icon name={icon} size={SIZE[size].icon} />
{badge !== undefined && <Badge badge={badge} size={size} />}
</>
);
if ('href' in rest && rest.href !== undefined) {
const link = rest as React.AnchorHTMLAttributes<HTMLAnchorElement>;
return (
<a
{...link}
aria-label={name}
onPointerDown={(e) => {
iconPress(e);
link.onPointerDown?.(e);
}}
className={classes}
>
{inner}
</a>
);
}
const { type = 'button', ...buttonRest } = rest as React.ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button
type={type}
{...buttonRest}
aria-label={name}
onPointerDown={(e) => {
iconPress(e);
buttonRest.onPointerDown?.(e);
}}
onClick={(e) => {
if (e.detail === 0) iconPress(e);
buttonRest.onClick?.(e);
}}
className={classes}
>
{inner}
</button>
);
}Häufige Fragen
Wie installiere ich Icon-Knopf?
Mit aiw add @ai-website/icon-button. Der Befehl legt die Datei components/ui/icon-button.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Icon-Knopf?
3 Varianten, Stile und Größen, Zahl und Punkt und Im Einsatz.
Kann ein KI-Assistent Icon-Knopf einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Icon-Knopf mit aiw_components und installieren sie mit aiw_add @ai-website/icon-button. Nenn im Prompt die Variante, etwa variant="outline", dann schreibt der Assistent deine Inhalte direkt in die Props.