Zum Inhalt springen
UI Kit
Accent

Linkliste

Listen von Links: ein Seitenmenü mit aktivem Eintrag, gruppierte Einstellungszeilen und eine ruhige Ressourcenliste.

Eine von 6 Components in Navigation

3 Variants

Variants

Details

Was Linkliste mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Navigation
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/link-list.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle LinkListProps aus components/ui/link-list.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • sectionsPflicht
    LinkListSection[]
  • variantoptional
    'nav' | 'rows' | 'plain'

    nav: a side menu with an active item. rows: grouped rows with chevrons, like phone settings. plain: a quiet list of links with arrows.

  • labeloptional
    string

    Accessible name when it is a navigation

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-current und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • 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. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 184 Zeilen
components/ui/link-list.tsx
'use client';

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

export interface LinkListItem {
  label: string;
  href: string;
  icon?: IconName;
  description?: React.ReactNode;
  /** A count or short status at the end */
  badge?: React.ReactNode;
  /** Text on the right, e.g. the current value of a setting */
  value?: React.ReactNode;
  active?: boolean;
  external?: boolean;
  destructive?: boolean;
}

export interface LinkListSection {
  title?: string;
  items: LinkListItem[];
}

export interface LinkListProps {
  sections: LinkListSection[];
  /** nav: a side menu with an active item. rows: grouped rows with chevrons, like phone settings. plain: a quiet list of links with arrows. */
  variant?: 'nav' | 'rows' | 'plain';
  /** Accessible name when it is a navigation */
  label?: string;
  className?: string;
}

/* Rows rise one after another when the list scrolls into view */
const STYLES =
  '.kitfx-ll-in .kitfx-ll-row{animation:kitfx-ll-in .55s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 45ms)}' +
  '@keyframes kitfx-ll-in{from{opacity:0;transform:translateY(8px)}}' +
  '.kitfx-ll-in .kitfx-ll-bar{animation:kitfx-ll-bar .5s cubic-bezier(.34,1.56,.64,1) both;animation-delay:calc(var(--i,0) * 45ms + 250ms)}' +
  '@keyframes kitfx-ll-bar{from{transform:scaleY(0)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-ll-in .kitfx-ll-row,.kitfx-ll-in .kitfx-ll-bar{animation:none}}';

/** In a side menu one soft surface follows the pointer and the focus from row to row */
function useGlide() {
  const [glide, setGlide] = useState<{ y: number; h: number; on: boolean; jump: boolean } | null>(null);
  const to = (el: HTMLElement | null) => {
    if (!el || prefersReducedMotion()) return;
    const y = el.offsetTop;
    const h = el.offsetHeight;
    setGlide((g) => ({ y, h, on: true, jump: !g || !g.on }));
  };
  const off = () => setGlide((g) => (g ? { ...g, on: false } : g));
  return [glide, to, off] as const;
}

function NavSection({ items, start }: { items: LinkListItem[]; start: number }) {
  const [glide, to, off] = useGlide();
  return (
    <ul className="relative isolate grid gap-0.5" onPointerLeave={off} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && off()}>
      {glide && (
        <span
          aria-hidden
          className={cn(
            'pointer-events-none absolute inset-x-0 top-0 -z-10 rounded-lg bg-[color-mix(in_oklab,var(--accent)_70%,transparent)] transition-[opacity,transform] ease-[var(--ease-kit)]',
            glide.jump ? 'duration-150 [transition-property:opacity]' : 'duration-300',
            glide.on ? 'opacity-100' : 'opacity-0',
          )}
          style={{ height: glide.h, transform: `translateY(${glide.y}px)` }}
        />
      )}
      {items.map((it, i) => (
        <li
          key={it.label}
          className="kitfx-ll-row"
          style={{ '--i': start + i } as React.CSSProperties}
          onPointerEnter={(e) => e.pointerType === 'mouse' && to(e.currentTarget)}
          onFocus={(e) => to(e.currentTarget)}
        >
          <Row it={it} variant="nav" order={start + i} />
        </li>
      ))}
    </ul>
  );
}

function Row({ it, variant, order }: { it: LinkListItem; variant: 'nav' | 'rows' | 'plain'; order: number }) {
  return (
    <a
      href={it.href}
      aria-current={it.active ? 'page' : undefined}
      {...(it.external ? { target: '_blank', rel: 'noreferrer' } : {})}
      className={cn(
        'group/link flex items-center gap-3 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
        variant === 'nav' && cn('relative min-h-10 rounded-lg px-3 text-sm max-md:min-h-11', it.active ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:text-foreground'),
        variant === 'rows' && cn('min-h-[3.25rem] px-4 py-2.5 text-[15px] hover:bg-[color-mix(in_oklab,var(--accent)_60%,transparent)]', it.destructive ? 'text-destructive' : 'text-foreground'),
        variant === 'plain' && 'min-h-14 py-3 text-[15px] text-foreground hover:text-foreground',
      )}
    >
      {variant === 'nav' && it.active && <span aria-hidden className="kitfx-ll-bar absolute inset-y-2.5 start-1 w-[3px] origin-center rounded-full bg-primary" style={{ '--i': order } as React.CSSProperties} />}
      {it.icon &&
        (variant === 'rows' ? (
          <span className={cn('grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-surface transition-[scale,color] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover/link:scale-105 motion-reduce:transition-none', it.destructive ? 'text-destructive' : 'text-muted-foreground group-hover/link:text-foreground')}>
            <Icon name={it.icon} size={16} />
          </span>
        ) : (
          <Icon name={it.icon} size={17} className={cn('shrink-0 transition-[color,translate] duration-200 ease-[var(--ease-kit)] group-hover/link:translate-x-0.5 motion-reduce:transition-none', it.active ? 'text-foreground' : 'text-subtle-foreground group-hover/link:text-muted-foreground')} />
        ))}
      <span className="min-w-0 flex-1">
        <span className={cn('block truncate', variant === 'plain' && 'font-medium')}>
          {variant === 'plain' ? (
            // The underline grows from the start under the label
            <span className="relative after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-left after:scale-x-0 after:bg-current after:opacity-50 after:transition-transform after:duration-300 after:ease-[var(--ease-kit)] group-hover/link:after:scale-x-100 group-focus-visible/link:after:scale-x-100 motion-reduce:after:transition-none">{it.label}</span>
          ) : (
            it.label
          )}
        </span>
        {it.description && <span className="mt-0.5 block text-pretty text-[13px] font-normal text-muted-foreground">{it.description}</span>}
      </span>
      {it.value && <span className="max-w-[45%] shrink-0 truncate text-sm text-muted-foreground">{it.value}</span>}
      {it.badge !== undefined && (
        <span className={cn('grid h-5 min-w-5 shrink-0 place-items-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums', it.active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')}>{it.badge}</span>
      )}
      {variant !== 'nav' && (
        <Icon
          name={it.external ? 'arrow-up-right' : variant === 'rows' ? 'chevron-right' : 'arrow-right'}
          size={16}
          className={cn(
            'shrink-0 text-subtle-foreground transition-[translate,color] duration-300 ease-[var(--ease-kit)] group-hover/link:text-foreground group-focus-visible/link:text-foreground motion-reduce:transition-none',
            it.external ? 'group-hover/link:-translate-y-0.5 group-hover/link:translate-x-0.5' : 'group-hover/link:translate-x-1 group-focus-visible/link:translate-x-1',
          )}
        />
      )}
    </a>
  );
}

/**
 * Lists of links: side navigation, settings rows or a resource list. Rows arrive one after another,
 * arrows glide on hover, in the side menu a soft surface follows the pointer.
 *   <LinkList variant="rows" sections={[{ title: 'Account', items: [{ label: 'Profile', href: '#', icon: 'user', value: 'Mia' }] }]} />
 */
export function LinkList({ sections, variant = 'nav', label, className }: LinkListProps) {
  const Wrap = (variant === 'nav' ? 'nav' : 'div') as 'nav';
  const [ref, inView] = useInView<HTMLElement>();
  const waited = useRef(false);
  if (!inView) waited.current = true;
  const entering = inView && waited.current;
  let order = 0;
  return (
    <Wrap
      ref={ref as React.Ref<HTMLElement>}
      aria-label={variant === 'nav' ? label : undefined}
      className={cn('grid w-full grid-cols-[minmax(0,1fr)]', variant === 'rows' ? 'gap-7' : 'gap-6', !inView && '[&_.kitfx-ll-row]:opacity-0', entering && 'kitfx-ll-in', className)}
    >
      <style href="kitfx-ll" precedence="default">
        {STYLES}
      </style>
      {sections.map((sec, si) => {
        const start = order;
        order += sec.items.length + (sec.title ? 1 : 0);
        return (
          <div key={sec.title ?? si}>
            {sec.title && (
              <p className={cn('kitfx-ll-row text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground', variant === 'rows' ? 'mb-2 px-4' : 'mb-2 px-3')} style={{ '--i': start } as React.CSSProperties}>
                {sec.title}
              </p>
            )}
            {variant === 'nav' ? (
              <NavSection items={sec.items} start={start + (sec.title ? 1 : 0)} />
            ) : (
              <ul className={cn(variant === 'rows' && 'divide-y divide-border overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', variant === 'plain' && 'divide-y divide-border border-y border-border')}>
                {sec.items.map((it, i) => (
                  <li key={it.label} className="kitfx-ll-row" style={{ '--i': start + (sec.title ? 1 : 0) + i } as React.CSSProperties}>
                    <Row it={it} variant={variant} order={start + i} />
                  </li>
                ))}
              </ul>
            )}
          </div>
        );
      })}
    </Wrap>
  );
}

Häufige Fragen

Wie installiere ich Linkliste?

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

Welche Varianten hat Linkliste?

3 Varianten, Seitenmenü, Einstellungszeilen und Ressourcenliste. Du wählst sie mit der Prop variant, zum Beispiel variant="plain".

Kann ein KI-Assistent Linkliste einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert