Mikkel Manniche
Dansk English Deutsch

Skabelon · React · HTML

Contact People

Et gitter af personkort med rolle, sprog, en e-mail at kopiere og et valgfrit link til booking, filtreret efter team.

Forhåndsvisning

React

Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/contact-people.json
npx shadcn@latest add @manniche/contact-people
contact-people.tsx
// Contact people: a grid of person cards (initials or image avatar, name, role, language chips, email with a copy
// button, optional "Book a call"). A team filter appears as Pills when there is more than one team.
// Signature: copying an email shows "Copied" in place, announced through a live region.
// Screen readers: a section named by its h2, a list of people with an h3 each; the filter is a radio group and the
// result count is announced. Reduced motion: the press feedback on the buttons is dropped; nothing else moves.
import { useEffect, useId, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Pills } from '@/registry/manniche/chart-kit/chart-kit'

export type ContactPerson = {
  id: string
  name: string
  role: string
  /** Team name; used for the filter. */
  team?: string
  /** Language chips: "English", "Deutsch". */
  languages?: string[]
  email: string
  /** Optional booking link. */
  bookHref?: string
  /** Image slot; initials are shown when absent. */
  avatar?: ReactNode
}

export type ContactPeopleProps = Omit<HTMLAttributes<HTMLElement>, 'title'> & {
  /** The section heading (h2). */
  heading: ReactNode
  intro?: ReactNode
  people: ContactPerson[]
  labels?: { all?: string; filter?: string; copy?: string; copied?: string; failed?: string; book?: string; languages?: string; shown?: (n: number) => string }
}

const initials = (name: string) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase()).join('')

function CopyEmail({ email, L }: { email: string; L: { copy: string; copied: string; failed: string } }) {
  const [s, setS] = useState<'idle' | 'copied' | 'failed'>('idle')
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(timer.current), [])
  const run = async () => {
    try {
      await navigator.clipboard.writeText(email)
      setS('copied')
    } catch {
      setS('failed')
    }
    clearTimeout(timer.current)
    timer.current = setTimeout(() => setS('idle'), 1800)
  }
  return (
    <>
      <button type="button" onClick={run}
        className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-muted px-3.5 text-sm font-medium outline-offset-2 transition-[opacity,transform] duration-200 ease-out-quint focus-visible:outline-2 focus-visible:outline-ring active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100">
        {s === 'copied' ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
        <span aria-hidden className="font-mono text-xs">{s === 'copied' ? L.copied : s === 'failed' ? L.failed : L.copy}</span>
        <span className="sr-only">{L.copy}: {email}</span>
      </button>
      <span role="status" aria-live="polite" className="sr-only">{s === 'copied' ? L.copied : s === 'failed' ? L.failed : ''}</span>
    </>
  )
}

export function ContactPeople({ heading, intro, people, labels = {}, className, ...rest }: ContactPeopleProps) {
  const L = { all: 'Everyone', filter: 'Team', copy: 'Copy email', copied: 'Copied', failed: 'Not copied', book: 'Book a call', languages: 'Languages', shown: (n: number) => `${n} people shown`, ...labels }
  const uid = useId()
  const teams = Array.from(new Set(people.map((p) => p.team).filter((t): t is string => !!t)))
  const [team, setTeam] = useState('all')
  // A team that is no longer in `people` falls back to everyone, so the list never ends up empty with no way out.
  const active = team === 'all' || teams.includes(team) ? team : 'all'
  const list = active === 'all' ? people : people.filter((p) => p.team === active)
  return (
    <section aria-labelledby={`${uid}-h`} className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6', className)} {...rest}>
      <header className="flex flex-wrap items-end justify-between gap-4">
        <div className="max-w-2xl">
          <h2 id={`${uid}-h`} className="font-[family-name:var(--font-display,inherit)] text-3xl font-semibold tracking-tight text-balance @min-[40rem]:text-4xl">{heading}</h2>
          {intro && <p className="mt-3 text-muted-foreground">{intro}</p>}
        </div>
        {teams.length > 1 && (
          <Pills label={L.filter} value={active} onChange={setTeam} options={[{ id: 'all', label: L.all }, ...teams.map((t) => ({ id: t, label: t }))]} />
        )}
      </header>
      <p role="status" aria-live="polite" className="sr-only">{L.shown(list.length)}</p>
      <ul className="mt-8 grid list-none gap-3 p-0 @min-[34rem]:grid-cols-2 @min-[62rem]:grid-cols-3">
        {list.map((p) => (
          <li key={p.id} className="min-w-0">
            <article className="flex h-full flex-col rounded-[26px] bg-card p-6 text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]">
              <div className="flex items-center gap-3">
                <span className="grid size-12 shrink-0 place-items-center overflow-hidden rounded-full bg-muted font-mono text-sm" aria-hidden={p.avatar ? undefined : true}>
                  {p.avatar ?? initials(p.name)}
                </span>
                <div className="min-w-0">
                  <h3 className="text-lg font-semibold break-words text-pretty">{p.name}</h3>
                  <p className="text-sm text-muted-foreground">{p.role}</p>
                </div>
              </div>
              {p.languages && p.languages.length > 0 && (
                <ul aria-label={L.languages} className="mt-4 flex list-none flex-wrap gap-1.5 p-0">
                  {p.languages.map((l) => (
                    <li key={l} className="rounded-full bg-muted px-2.5 py-1 text-xs">{l}</li>
                  ))}
                </ul>
              )}
              <p className="mt-4 font-mono text-xs break-all text-muted-foreground">{p.email}</p>
              <div className="mt-auto flex flex-wrap gap-2 pt-4">
                <CopyEmail email={p.email} L={L} />
                {p.bookHref && (
                  <a href={p.bookHref} className="inline-flex min-h-11 items-center rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring">
                    {L.book}<span className="sr-only">: {p.name}</span>
                  </a>
                )}
              </div>
            </article>
          </li>
        ))}
      </ul>
    </section>
  )
}
contact-people-demo.tsx
import { ContactPeople } from '@/components/contact-people'

export default function ContactPeopleDemo() {
  return (
    <ContactPeople
      heading="Talk to a person"
      intro="Invented people at an invented studio."
      people={[
        { id: 'a', name: 'Anna Beispiel', role: 'Studio lead', team: 'Munich', languages: ['English', 'Deutsch'], email: 'anna@example.com', bookHref: 'https://example.com/book/anna' },
        { id: 'b', name: 'Jonas Eksempel', role: 'Design engineer', team: 'Aalborg', languages: ['English', 'Dansk'], email: 'jonas@example.com' },
        { id: 'c', name: 'Mira Muster', role: 'Producer', team: 'Munich', languages: ['English', 'Deutsch'], email: 'mira@example.com', bookHref: 'https://example.com/book/mira' },
        { id: 'd', name: 'Sofie Test', role: 'Support', team: 'Aalborg', languages: ['English', 'Dansk'], email: 'sofie@example.com' },
      ]}
    />
  )
}

HTML

Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.

Åbn i ny fane

Farven i din egen app

Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .dark.

:root {
  --primary: oklch(0.52 0.2 262);
  --primary-foreground: oklch(0.99 0 0);
  --accent: oklch(0.95 0.034 262);
  --accent-foreground: oklch(0.36 0.140 262);
  --ring: oklch(0.52 0.2 262);
}

.dark {
  --primary: oklch(0.74 0.150 262);
  --primary-foreground: oklch(0.18 0.024 262);
  --accent: oklch(0.29 0.050 262);
  --accent-foreground: oklch(0.9 0.050 262);
  --ring: oklch(0.74 0.150 262);
}

Installér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/contact-people.json

Afhængigheder

Filer

  • contact-people.tsx
  • contact-people.html

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.