Zum Inhalt springen
UI Kit
Accent

Button Component für React

Fünf Stile, drei Größen, Icons, Laden, Gruppen, Icon-Knöpfe und neutrale Anmeldeknöpfe.

Eine von 6 Components in Buttons

7 Variants

Stile, Größen und Zustände

Der Button ist die meistgenutzte Component jeder Oberfläche, und er gibt den Ton für alles andere an. Dieser kommt in fünf Stilen, Primary, Secondary, Outline, Ghost und Destructive, in drei Größen, und er rendert einen echten Link, wenn du href übergibst, sonst einen echten Button.

Stile und Größen zeigen die Rangfolge auf einen Blick. „Mit Icons“ setzt ein Icon vor oder hinter das Label, im passenden Abstand. „Laden“ behält beim Arbeiten die Breite, das Layout springt also nicht. „Gruppen“ verbindet Buttons zu einer Reihe, Icon-Knöpfe sind quadratisch und verlangen ein aria-label, „Neutrale Anmeldung“ zeigt Weiter-Buttons für E-Mail und Passkey ganz ohne fremde Logos.

Gib jeder Ansicht einen Primary-Button. Halte die Größe in einer Reihe gleich, gemischte Größen wirken wie ein Fehler. Auf Touchscreens erreichen die Größen des Kits mindestens 44 Pixel Tap-Höhe, auch kleine Buttons bleiben gut treffbar.

Variants

Stile

Eine Hauptaktion je Ansicht, die anderen treten zurück. Ein Druck senkt den Knopf und hinterlässt ein Licht an der Druckstelle, die Hauptaktion folgt leicht der Maus.

Größen

Klein 36 px, mittel 44 px, groß 48 px. Mittel und groß erfüllen das Tap-Ziel von 44 px.

Mit Icons

Icons vorne benennen das Objekt, Icons hinten zeigen den Weg und rücken beim Hovern ein Stück.

Laden

Der Spinner wird zum gezeichneten Haken, die Breite gleitet zwischen den Texten, ein Fehler schüttelt einmal. Doppelklicks sind geblockt.

Gruppen

Verbundene Knöpfe teilen sich eine Kante, ein geteilter Knopf hält verwandte Aktionen einen Klick entfernt.

Icon-Knöpfe

Eckig oder rund, mit Pflicht-Label und optionaler Zahl. Drück die Glocke, die Zahl springt auf.

Neutrale Anmeldung

Optionen über die volle Breite für die Anmeldung, ohne fremde Logos gezeichnet. Eigene Anbieterzeichen dort einsetzen, wo die Rechte vorliegen.

Details

Was Knopf mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Buttons
Bereich
Elements
Variants
7
Version
1.0.1 · seit 1.0.0
Datei
components/ui/button.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 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, 190 Zeilen
components/ui/button.tsx
'use client';

import { useRef } from 'react';
import { cn, layoutClasses, prefersReducedMotion } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
export type ButtonSize = 'sm' | 'md' | 'lg';

const VARIANT: Record<ButtonVariant, string> = {
  primary:
    'kit-shine 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))]',
  destructive:
    'bg-destructive text-destructive-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.16),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--destructive)_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-foreground hover:bg-accent',
  link: 'text-foreground underline-offset-4 hover:underline px-0! h-auto! min-h-0!',
};

const SIZE: Record<ButtonSize, string> = {
  // Small buttons grow to 44 px on phones, the minimum for a finger
  sm: 'h-9 min-h-9 gap-1.5 rounded-md px-3.5 text-[13px] max-sm:h-11 max-sm:min-h-11',
  md: 'h-11 min-h-11 gap-2 rounded-lg px-5 text-sm',
  lg: 'h-12 min-h-12 gap-2 rounded-lg px-6 text-[15px]',
};

/* ------------------------------------------------------------------ */
/* Press feedback                                                      */
/* ------------------------------------------------------------------ */

/**
 * A short light at the point of the press, drawn with the Web Animations API
 * so nothing lives in the React tree. glow: a white burst of light for filled main
 * actions. ripple: a quiet ring of the text color for everything else.
 * The layer sits behind the label, clips to the radius and removes itself.
 * The element needs position relative and isolation (Button has both).
 */
export function pressLight(el: HTMLElement, clientX: number, clientY: number, kind: 'glow' | 'ripple' = 'ripple') {
  if (prefersReducedMotion() || typeof el.animate !== 'function') return;
  const r = el.getBoundingClientRect();
  if (!r.width || !r.height) return;
  const x = Math.min(Math.max(clientX - r.left, 0), r.width);
  const y = Math.min(Math.max(clientY - r.top, 0), r.height);
  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:${
    kind === 'glow'
      ? 'radial-gradient(circle closest-side, rgb(255 255 255 / 0.42), rgb(255 255 255 / 0.12) 55%, transparent)'
      : 'color-mix(in oklab, currentColor 13%, transparent)'
  };`;
  clip.append(dot);
  el.append(clip);
  const anim = dot.animate(
    [
      { transform: 'scale(0.08)', opacity: kind === 'glow' ? 1 : 0.9 },
      { transform: 'scale(1)', opacity: 0 },
    ],
    { duration: kind === 'glow' ? 700 : 560, easing: 'cubic-bezier(0.2,0.7,0.2,1)' },
  );
  const done = () => clip.remove();
  anim.onfinish = done;
  anim.oncancel = done;
}

/** Pointer press at the pointer, keyboard press (a click with detail 0) from the middle */
function usePress(kind: 'glow' | 'ripple' | null) {
  return {
    onPointerDown: (e: React.PointerEvent<HTMLElement>) => {
      if (kind && e.button === 0) pressLight(e.currentTarget, e.clientX, e.clientY, kind);
    },
    onClick: (e: React.MouseEvent<HTMLElement>) => {
      if (!kind || e.detail !== 0) return;
      const r = e.currentTarget.getBoundingClientRect();
      pressLight(e.currentTarget, r.left + r.width / 2, r.top + r.height / 2, kind);
    },
  };
}

/** Pulls its child a few pixels toward a mouse pointer. No effect on touch or with reduced motion. */
function MagneticWrap({ strength = 6, className, children }: { strength?: number; className?: string; children: React.ReactNode }) {
  const ref = useRef<HTMLSpanElement>(null);
  return (
    <span
      ref={ref}
      onPointerMove={(e) => {
        const el = ref.current;
        if (!el || e.pointerType !== 'mouse' || prefersReducedMotion()) return;
        const r = el.getBoundingClientRect();
        const x = ((e.clientX - r.left) / r.width - 0.5) * 2 * strength;
        const y = ((e.clientY - r.top) / r.height - 0.5) * 2 * strength;
        el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
      }}
      onPointerLeave={() => {
        if (ref.current) ref.current.style.transform = '';
      }}
      className={cn('inline-flex transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', className)}
    >
      {children}
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Button                                                              */
/* ------------------------------------------------------------------ */

interface Common {
  variant?: ButtonVariant;
  size?: ButtonSize;
  /** Icon after the label, e.g. arrow-right. Moves a little on hover. */
  icon?: IconName;
  /** Icon before the label */
  iconLeft?: IconName;
  /** Pulls the button a few pixels toward the mouse. For the one main action of a section. */
  magnetic?: boolean;
  className?: string;
  children?: React.ReactNode;
}

type AsLink = Common & { href: string; ref?: React.Ref<HTMLAnchorElement> } & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common>;
type AsButton = Common & { href?: undefined; ref?: React.Ref<HTMLButtonElement> } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof Common>;

/**
 * Button or link that looks like a button. With href it renders an <a>.
 * A press scales it down a little and leaves a light at the press point:
 * a glow on primary and destructive, a quiet ripple on the others.
 */
export function Button(props: AsLink | AsButton) {
  const { variant = 'primary', size = 'md', icon, iconLeft, magnetic, className, children, ...rest } = props;
  const press = usePress(variant === 'link' ? null : variant === 'primary' || variant === 'destructive' ? 'glow' : 'ripple');
  const classes = cn(
    'group/button relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium tracking-[-0.01em] transition-[background-color,color,box-shadow,transform,scale,translate] duration-200 ease-[var(--ease-kit)] active:scale-[0.97] active:duration-100 disabled:pointer-events-none disabled:opacity-50 motion-reduce:active:scale-100',
    SIZE[size],
    VARIANT[variant],
    className,
  );
  const inner = (
    <>
      {iconLeft && <Icon name={iconLeft} size={size === 'sm' ? 15 : 17} />}
      {children}
      {icon && <Icon name={icon} size={size === 'sm' ? 15 : 17} className="transition-transform duration-200 group-hover/button:translate-x-0.5 motion-reduce:transition-none" />}
    </>
  );
  let el: React.ReactNode;
  if ('href' in rest && rest.href !== undefined) {
    const link = rest as React.AnchorHTMLAttributes<HTMLAnchorElement>;
    el = (
      <a
        {...link}
        onPointerDown={(e) => {
          press.onPointerDown(e);
          link.onPointerDown?.(e);
        }}
        onClick={(e) => {
          press.onClick(e);
          link.onClick?.(e);
        }}
        className={classes}
      >
        {inner}
      </a>
    );
  } else {
    const { type = 'button', ...buttonRest } = rest as React.ButtonHTMLAttributes<HTMLButtonElement>;
    el = (
      <button
        type={type}
        {...buttonRest}
        onPointerDown={(e) => {
          press.onPointerDown(e);
          buttonRest.onPointerDown?.(e);
        }}
        onClick={(e) => {
          press.onClick(e);
          buttonRest.onClick?.(e);
        }}
        className={classes}
      >
        {inner}
      </button>
    );
  }
  // The wrapper takes over the button's place in the layout (width, flex, grid, self), so a
  // full-width magnetic button stays full width on phones
  return magnetic ? <MagneticWrap className={layoutClasses(className)}>{el}</MagneticWrap> : el;
}

Häufige Fragen

Wie installiere ich Knopf?

Mit aiw add @ai-website/button. Der Befehl legt die Datei components/ui/button.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Knopf?

7 Varianten, Stile, Größen, Mit Icons, Laden, Gruppen, Icon-Knöpfe und Neutrale Anmeldung. Du wählst sie mit der Prop variant, zum Beispiel variant="outline".

Kann ein KI-Assistent Knopf einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Knopf mit aiw_components und installieren sie mit aiw_add @ai-website/button. Nenn im Prompt die Variante, etwa variant="secondary|outline|ghost|link|destructive", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert