Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Stile und Größen

Kleine Größen behalten über einen unsichtbaren Rand eine Trefferfläche von 44 px.

Zahl und Punkt

Die Zahl gehört zum zugänglichen Namen, ein Punkt markiert Neues. Zahlen springen auf und bei jeder Änderung erneut, drück die erste Glocke.

Im Einsatz

Eine Dateizeile mit ruhigen Aktionen und Tooltips, die jede benennen.

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

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
components/ui/icon-button.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert