Zum Inhalt springen
UI Kit
Accent

Knopfgruppe

Knöpfe zu einem Bedienelement verbunden, als Reihe, Stapel oder geteilter Knopf mit Menü.

Eine von 6 Components in Buttons

3 Variants

Variants

Verbunden

Eine gemeinsame Kante, außen rund, innen eckig. Eine feine Akzentlinie gleitet zum Knopf unter der Maus oder im Fokus.

Geteilter Knopf

Die wahrscheinliche Aktion vorne, die Alternativen hinter dem Pfeil. Das Menü geht mit den Pfeiltasten.

Gestapelt

Dieselben Regeln auf der senkrechten Achse, etwa zum Umsortieren oder als seitliche Leiste.

Details

Was Knopfgruppe mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Buttons
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/button-group.tsx
Bringt mit
utilskitbuttondropdown-menuicon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 1 eigener Text
Studio
Im Code anpassen

Props

Die Schnittstelle ButtonGroupProps aus components/ui/button-group.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • orientationoptional
    'horizontal' | 'vertical'

    horizontal is the default

  • sizeoptional
    ButtonSize

    The size of the buttons inside, sets the outer radius

  • labeloptional
    string

    Accessible name of the group

  • childrenPflicht
    React.ReactNode

Eingebaute Texte

Diese Texte schreibt Knopfgruppe selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 1 Texte anzeigen
moreEnglisch More optionsDeutsch Weitere Optionen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle group.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 142 Zeilen
components/ui/button-group.tsx
'use client';

import { useRef } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Button, type ButtonSize, type ButtonVariant } from '@/components/ui/button';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/ui/icon';

const TEXTS = { en: { more: 'More options' }, de: { more: 'Weitere Optionen' } };

const RADIUS: Record<ButtonSize, { h: string; v: string }> = {
  sm: { h: '[&>*:first-child]:rounded-s-md [&>*:last-child]:rounded-e-md', v: '[&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md' },
  md: { h: '[&>*:first-child]:rounded-s-lg [&>*:last-child]:rounded-e-lg', v: '[&>*:first-child]:rounded-t-lg [&>*:last-child]:rounded-b-lg' },
  lg: { h: '[&>*:first-child]:rounded-s-lg [&>*:last-child]:rounded-e-lg', v: '[&>*:first-child]:rounded-t-lg [&>*:last-child]:rounded-b-lg' },
};

export interface ButtonGroupProps {
  /** horizontal is the default */
  orientation?: 'horizontal' | 'vertical';
  /** The size of the buttons inside, sets the outer radius */
  size?: ButtonSize;
  /** Accessible name of the group */
  label?: string;
  className?: string;
  children: React.ReactNode;
}

/**
 * Buttons joined into one control. Borders overlap by one pixel, so no line is ever doubled.
 * Pass Buttons (or IconButtons, DropdownMenus) as children.
 * A thin accent line glides to the button under the mouse or with keyboard focus.
 */
export function ButtonGroup({ orientation = 'horizontal', size = 'md', label, className, children }: ButtonGroupProps) {
  const h = orientation === 'horizontal';
  const ref = useRef<HTMLDivElement>(null);
  // The line is the group's own ::after, placed with CSS variables, so it never counts as a child
  const place = (target: EventTarget | null) => {
    const root = ref.current;
    if (!root || prefersReducedMotion()) return;
    const child = Array.from(root.children).find((c) => c.contains(target as Node)) as HTMLElement | undefined;
    if (!child) return;
    const st = root.style;
    if (h) {
      st.setProperty('--line-x', `${child.offsetLeft + 10}px`);
      st.setProperty('--line-y', `${child.offsetTop + child.offsetHeight - 4}px`);
      st.setProperty('--line-w', `${Math.max(8, child.offsetWidth - 20)}px`);
      st.setProperty('--line-h', '2px');
    } else {
      st.setProperty('--line-x', `${child.offsetLeft + 4}px`);
      st.setProperty('--line-y', `${child.offsetTop + 10}px`);
      st.setProperty('--line-w', '2px');
      st.setProperty('--line-h', `${Math.max(8, child.offsetHeight - 20)}px`);
    }
    if (root.dataset.line) return;
    root.dataset.line = 'on';
    // First placement without travel, the line only fades in; from then on it glides
    requestAnimationFrame(() => requestAnimationFrame(() => ref.current?.dataset.line && (ref.current.dataset.glide = 'on')));
  };
  const hide = () => {
    const root = ref.current;
    if (!root || root.contains(document.activeElement)) return;
    delete root.dataset.line;
    delete root.dataset.glide;
  };
  return (
    <div
      ref={ref}
      role="group"
      aria-label={label}
      onPointerOver={(e) => e.pointerType === 'mouse' && place(e.target)}
      onPointerLeave={hide}
      onFocus={(e) => place(e.target)}
      onBlur={(e) => {
        if (!ref.current?.contains(e.relatedTarget as Node)) {
          const root = ref.current;
          if (root) {
            delete root.dataset.line;
            delete root.dataset.glide;
          }
        }
      }}
      className={cn(
        'relative isolate inline-flex',
        'after:pointer-events-none after:absolute after:left-0 after:top-0 after:z-30 after:h-[var(--line-h,2px)] after:w-[var(--line-w,0px)] after:rounded-full after:bg-primary after:opacity-0 after:content-[""] after:[translate:var(--line-x,0px)_var(--line-y,0px)] after:transition-opacity after:duration-200',
        'data-line:after:opacity-100 data-glide:after:transition-[opacity,translate,width,height] data-glide:after:duration-300 data-glide:after:ease-[var(--ease-kit)]',
        h ? 'flex-row' : 'flex-col',
        '[&>*]:rounded-none [&>*:hover]:z-10 [&>*:focus-visible]:z-20 [&>*]:relative',
        '[&>span>*]:rounded-none [&>span:first-child>*]:rounded-[inherit] [&>span:last-child>*]:rounded-[inherit]',
        h ? '[&>*:not(:first-child)]:-ms-px' : '[&>*:not(:first-child)]:-mt-px',
        h ? RADIUS[size].h : RADIUS[size].v,
        className,
      )}
    >
      {children}
    </div>
  );
}

export interface SplitButtonProps {
  label: React.ReactNode;
  onClick?: () => void;
  href?: string;
  /** The other actions, behind the chevron */
  items: MenuItem[];
  variant?: Extract<ButtonVariant, 'primary' | 'outline' | 'secondary'>;
  size?: ButtonSize;
  iconLeft?: React.ComponentProps<typeof Button>['iconLeft'];
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

/** The main action plus a menu of related ones, e.g. Publish / Schedule / Save as draft. */
export function SplitButton({ label, onClick, href, items, variant = 'primary', size = 'md', iconLeft, labels, className }: SplitButtonProps) {
  const t = useTexts(TEXTS, labels);
  const divider =
    variant === 'primary'
      ? 'before:absolute before:inset-y-2.5 before:start-0 before:w-px before:bg-[color-mix(in_oklab,var(--primary-foreground)_24%,transparent)]'
      : '';
  const chevronWidth = size === 'sm' ? 'w-9 px-0!' : size === 'md' ? 'w-11 px-0!' : 'w-12 px-0!';
  return (
    <ButtonGroup size={size} className={className}>
      {href ? (
        <Button href={href} variant={variant} size={size} iconLeft={iconLeft}>
          {label}
        </Button>
      ) : (
        <Button onClick={onClick} variant={variant} size={size} iconLeft={iconLeft}>
          {label}
        </Button>
      )}
      <DropdownMenu
        align="end"
        items={items}
        trigger={
          <Button variant={variant} size={size} aria-label={t.more} className={cn('relative rounded-s-none rounded-e-[inherit]', chevronWidth, divider, variant === 'primary' && 'ms-0')}>
            <Icon name="chevron-down" size={size === 'sm' ? 15 : 17} className="transition-[rotate] duration-300 ease-[var(--ease-kit)] group-aria-expanded/button:rotate-180 motion-reduce:transition-none" />
          </Button>
        }
      />
    </ButtonGroup>
  );
}

Häufige Fragen

Wie installiere ich Knopfgruppe?

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

Welche Varianten hat Knopfgruppe?

3 Varianten, Verbunden, Geteilter Knopf und Gestapelt.

Kann ich die Texte von Knopfgruppe übersetzen?

Ja. Knopfgruppe bringt 1 eigene Texte auf Deutsch und Englisch mit, etwa „Weitere Optionen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Knopfgruppe einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Knopfgruppe mit aiw_components und installieren sie mit aiw_add @ai-website/button-group. 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