Mikkel Manniche
Dansk English Deutsch

Skabelon · React · HTML

Contact Channels

Én flise pr. måde at nå et team på, med hvor hurtigt hver svarer og den anbefalede i mørkt, og en kort FAQ nedenunder.

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-channels.json
npx shadcn@latest add @manniche/contact-channels
contact-channels.tsx
// Contact channels: one tile per way to reach a team (email, phone, chat, in person), the recommended one inverted,
// and a short FAQ underneath. Signature: every channel says how fast it answers, in mono.
// Screen readers get a section named by its h2, a real list of channels with an h3 each, and a FAQ of native
// <details> elements. Under reduced motion the answer simply appears.
import { useId, type HTMLAttributes, type ReactNode } from 'react'
import { ArrowUpRight } from 'lucide-react'
import { cn } from '@/lib/utils'

export type ContactChannel = {
  /** Stable key for the channel. */
  id: string
  /** A small icon node, e.g. a lucide icon. Decorative. */
  icon?: ReactNode
  /** The channel name: "Email", "Phone". */
  title: ReactNode
  /** The address, number or opening line. */
  detail: ReactNode
  /** The one link or button of the tile. */
  action: { label: string; href: string; external?: boolean }
  /** How fast it answers, in mono: "Within 2 working days". */
  responseTime?: string
}

export type ContactFaq = { question: string; answer: ReactNode }

export type ContactChannelsProps = Omit<HTMLAttributes<HTMLElement>, 'title'> & {
  /** The section heading (h2). */
  heading: ReactNode
  /** One line under the heading. */
  intro?: ReactNode
  /** The channels, in reading order. */
  channels: ContactChannel[]
  /** The id of the recommended channel, shown as the inverted tile. */
  recommendedId?: string
  /** Short questions and answers below the channels. */
  faq?: ContactFaq[]
  /** Heading of the FAQ (h3). */
  faqHeading?: ReactNode
  /** UI strings, for translation. */
  labels?: { recommended?: string; responds?: string; newTab?: string }
}

export function ContactChannels({
  heading,
  intro,
  channels,
  recommendedId,
  faq = [],
  faqHeading = 'Quick answers',
  labels = {},
  className,
  ...rest
}: ContactChannelsProps) {
  const { recommended = 'Recommended', responds = 'Answers', newTab = '(opens in a new tab)' } = labels
  const hid = useId()
  return (
    <section aria-labelledby={hid} className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6', className)} {...rest}>
      <header className="max-w-2xl">
        <h2 id={hid} 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>}
      </header>

      <ul className="mt-8 grid list-none gap-3 p-0 @min-[36rem]:grid-cols-2 @min-[64rem]:grid-cols-4">
        {channels.map((c) => {
          const inverted = c.id === recommendedId
          return (
            <li key={c.id} className="min-w-0">
              <article
                data-inverted={inverted || undefined}
                className={cn(
                  'flex h-full flex-col rounded-[26px] p-6',
                  inverted
                    ? 'bg-foreground text-background'
                    : 'bg-card 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)]',
                )}
                style={
                  inverted
                    ? ({
                        '--muted-foreground': 'color-mix(in oklab, var(--background) 64%, var(--foreground))',
                        '--ring': 'var(--background)',
                      } as React.CSSProperties)
                    : undefined
                }
              >
                <div className="flex items-center justify-between gap-2">
                  <span
                    aria-hidden
                    className={cn('grid size-10 place-items-center rounded-[14px] [&_svg]:size-5', inverted ? 'bg-background/12' : 'bg-muted')}
                  >
                    {c.icon}
                  </span>
                  {inverted && (
                    <span className="rounded-full bg-background/12 px-2.5 py-1 font-mono text-[11px] tracking-wide uppercase">{recommended}</span>
                  )}
                </div>
                <h3 className="mt-5 text-lg font-semibold">{c.title}</h3>
                <p className="mt-1 break-words text-sm text-muted-foreground">{c.detail}</p>
                {c.responseTime && (
                  <p className="mt-4 font-mono text-[11px] tabular-nums text-muted-foreground">
                    {responds}: {c.responseTime}
                  </p>
                )}
                <a
                  href={c.action.href}
                  {...(c.action.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
                  className={cn(
                    'mt-5 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-full px-4 text-sm font-medium',
                    'transition-[opacity,transform] duration-200 ease-out-quint active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100',
                    'outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring',
                    inverted ? 'bg-background text-foreground' : 'bg-primary text-primary-foreground',
                  )}
                >
                  {c.action.label}
                  {c.action.external && <span className="sr-only">{newTab}</span>}
                  <ArrowUpRight className="size-4" aria-hidden />
                </a>
              </article>
            </li>
          )
        })}
      </ul>

      {faq.length > 0 && (
        <div className="mt-10 max-w-3xl">
          <h3 className="text-lg font-semibold">{faqHeading}</h3>
          <div className="mt-3 divide-y divide-border rounded-[26px] bg-card px-6 shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)]">
            {faq.map((f) => (
              <details key={f.question} className="group">
                <summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 py-3 text-sm font-medium outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden">
                  {f.question}
                  <span aria-hidden className="font-mono text-muted-foreground group-open:hidden">+</span>
                  <span aria-hidden className="hidden font-mono text-muted-foreground group-open:inline">-</span>
                </summary>
                <div className="pb-4 text-sm text-muted-foreground motion-safe:group-open:animate-[contact-faq-in_250ms_cubic-bezier(0.22,1,0.36,1)]">
                  {f.answer}
                </div>
              </details>
            ))}
          </div>
          <style>{`@keyframes contact-faq-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}`}</style>
        </div>
      )}
    </section>
  )
}
contact-channels-demo.tsx
import { Mail, MessageCircle, Phone, MapPin } from 'lucide-react'
import { ContactChannels } from '@/components/contact-channels'

export default function ContactChannelsDemo() {
  return (
    <ContactChannels
      heading="Pick the way that suits you"
      intro="Halden Studio is a small team in Munich and Aalborg. Every channel below goes to a real person."
      recommendedId="email"
      channels={[
        { id: 'email', icon: <Mail />, title: 'Email', detail: 'hello@example.com', action: { label: 'Write to us', href: 'mailto:hello@example.com' }, responseTime: 'within 2 working days' },
        { id: 'phone', icon: <Phone />, title: 'Phone', detail: '+49 89 0000 0000, Mon to Fri, 09:00 to 17:00', action: { label: 'Call', href: 'tel:+498900000000' }, responseTime: 'at once in opening hours' },
        { id: 'chat', icon: <MessageCircle />, title: 'Chat', detail: 'A short message in our chat room.', action: { label: 'Open chat', href: 'https://example.com/chat', external: true }, responseTime: 'within 1 hour' },
        { id: 'visit', icon: <MapPin />, title: 'In person', detail: 'Example Street 1, Munich. By appointment.', action: { label: 'Book a visit', href: 'https://example.com/visit', external: true }, responseTime: 'within 1 working day' },
      ]}
      faq={[
        { question: 'Which channel is the fastest?', answer: 'Chat during opening hours. Email is best when you want to attach files.' },
        { question: 'Do you work in other languages?', answer: 'Yes. We answer in English, German and Danish.' },
        { question: 'Can I send a project brief?', answer: 'Yes. Email it to us, and say what you need and by when.' },
      ]}
    />
  )
}

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-channels.json

Afhængigheder

Filer

  • contact-channels.tsx
  • contact-channels.html

Manniche Lab · open source

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