UI

Contact Channels

A hairline grid of contact cells with copy-to-clipboard actions, single-key shortcuts, tooltips, and blueprint hover styling.

Props

Columns

Installation

Contact Channels is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/contact-channels

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the components page.

Usage

import { ContactChannels } from "@/components/ui/contact-channels";
import { Github, Mail } from "lucide-react";
 
const channels = [
  {
    id: "github",
    platform: "github",
    handle: "@octocat",
    href: "https://github.com",
    action: "external",
    icon: <Github className="size-4" />,
  },
  {
    id: "email",
    platform: "email",
    handle: "hi@example.com",
    action: "copy",
    copyValue: "hi@example.com",
    tooltip: "copy email",
    shortcutKey: "e",
    icon: <Mail className="size-4" />,
  },
];
 
export function Contact() {
  return <ContactChannels items={channels} columns={2} />;
}

Props

PropTypeDefaultNotes
items RequiredContactChannelItem[]-Each item: id, platform, handle, href, action ("copy" or "external"), copyValue, tooltip, shortcutKey, icon (a ReactNode).
columns1 | 2 | 32Grid column count. Collapses to a single column on small screens.
classNamestring—Extra styling classes forwarded to the grid.

Notes & Features

  • Copy or link per cell. Cells with action: "copy" write copyValue to the clipboard and flip the trailing icon to a check for two seconds; cells with action: "external" open href in a new tab with a rotating arrow.
  • Single-key shortcuts. Copy cells may declare a shortcutKey; a window-level listener triggers the copy while no input, textarea, or contenteditable element has focus.
  • Icons as nodes. Icons are plain ReactNodes, so any icon set works — lucide, react-icons, or local SVG components. Nothing is imported on your behalf.
  • Hairline grid. The wrapper draws the top and left rules, each cell draws its bottom and right rule — one border per edge, dashed to match the blueprint language.
  • Accessible. Every cell is a real <button> or <a> with visible focus rings and tooltip labels.

Manual installation

Rather not use the CLI? Everything Contact Channels needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install lucide-react motion radix-ui

shadcn primitives

$npx shadcn@latest add tooltip

Source

components/ui/contact-channels.tsx208 lines
"use client";

import { cn } from "@/lib/utils";
import { ArrowRightIcon, Check, Copy } from "lucide-react";
import { motion } from "motion/react";
import {
  useCallback,
  useEffect,
  useMemo,
  useState,
  type ReactNode,
} from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";

export type ContactChannelAction = "copy" | "external";

export interface ContactChannelItem {
  id: string;
  platform: string;
  handle?: string;
  href?: string;
  action?: ContactChannelAction;
  /** Text placed on the clipboard when `action` is `"copy"`. */
  copyValue?: string;
  /** Tooltip label. Falls back to `"copy"` / the platform name. */
  tooltip?: string;
  /** Single keyboard key that triggers the copy action. */
  shortcutKey?: string;
  icon?: ReactNode;
}

export interface ContactChannelsProps {
  items: ContactChannelItem[];
  /** Grid column count (collapses to one column on small screens). */
  columns?: 1 | 2 | 3;
  className?: string;
}

const COLUMN_CLASSES: Record<1 | 2 | 3, string> = {
  1: "grid-cols-1",
  2: "grid-cols-2 max-sm:grid-cols-1",
  3: "grid-cols-3 max-sm:grid-cols-1",
};

/** Inline kbd, styled for use inside a tooltip. */
function Kbd({ children }: { children: ReactNode }) {
  return (
    <kbd className="pointer-events-none ml-1 inline-flex h-5 min-w-5 select-none items-center justify-center rounded-sm bg-background/20 px-1 font-sans text-xs font-medium text-background">
      {children}
    </kbd>
  );
}

export function ContactChannels({
  items,
  columns = 2,
  className,
}: ContactChannelsProps) {
  const [copied, setCopied] = useState<Record<string, boolean>>({});

  const copyItems = useMemo(
    () => items.filter((item) => item.action === "copy" && item.copyValue),
    [items],
  );

  const handleCopy = useCallback((item: ContactChannelItem) => {
    if (!item.copyValue) {
      return;
    }
    navigator.clipboard.writeText(item.copyValue);
    setCopied((prev) => ({ ...prev, [item.id]: true }));
    window.setTimeout(
      () => setCopied((prev) => ({ ...prev, [item.id]: false })),
      2000,
    );
  }, []);

  // Single-key copy shortcuts. Ignored while typing in editable targets.
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement ||
        (e.target as HTMLElement).isContentEditable
      ) {
        return;
      }

      const target = copyItems.find(
        (item) => item.shortcutKey?.toLowerCase() === e.key.toLowerCase(),
      );
      if (!target) {
        return;
      }

      e.preventDefault();
      handleCopy(target);
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [copyItems, handleCopy]);

  return (
    <div
      className={cn(
        "grid w-full overflow-hidden border-t border-l border-dashed",
        COLUMN_CLASSES[columns],
        className,
      )}
    >
      {items.map((item, idx) => {
        const isCopyAction = item.action === "copy";
        const isCopied = Boolean(copied[item.id]);

        const cellContent = (
          <motion.div
            initial={{ opacity: 0, y: 10 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true }}
            transition={{
              duration: 0.22,
              delay: idx * 0.045,
              ease: [0.22, 1, 0.36, 1],
            }}
            className="relative flex h-full items-center gap-3 overflow-hidden p-4 text-left transition-colors group-hover:bg-muted/10 group-focus-visible:bg-muted/10"
          >
            <div className="blueprint-bg pointer-events-none absolute inset-0 opacity-45 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />

            <div className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-muted-foreground/5 transition-colors group-hover:ring-muted-foreground/10 group-focus-visible:ring-muted-foreground/10" />

            <div className="relative z-10 flex w-full items-center gap-3">
              {item.icon ? (
                <div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-sm bg-background text-muted-foreground transition-all group-hover:-translate-y-0.5 group-focus-visible:-translate-y-0.5 group-hover:text-foreground group-focus-visible:text-foreground">
                  {item.icon}
                  <div className="pointer-events-none absolute inset-0 rounded-sm ring-1 ring-inset ring-muted-foreground/5" />
                </div>
              ) : null}

              <div className="flex min-w-0 flex-col">
                <span className="text-sm font-medium leading-none">
                  {item.platform}
                </span>
                {item.handle ? (
                  <span className="mt-1 truncate font-mono text-xs text-muted-foreground">
                    {item.handle}
                  </span>
                ) : null}
              </div>

              {isCopyAction ? (
                <div className="ml-auto">
                  {isCopied ? (
                    <Check className="h-3.5 w-3.5 text-green-500" />
                  ) : (
                    <Copy className="h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground group-focus-visible:text-foreground" />
                  )}
                </div>
              ) : (
                <ArrowRightIcon className="ml-auto size-3.5 text-muted-foreground/30 transition-transform group-hover:translate-x-0.5 group-hover:-rotate-45 group-hover:text-primary group-focus-visible:translate-x-0.5 group-focus-visible:-rotate-45 group-focus-visible:text-primary" />
              )}
            </div>
          </motion.div>
        );

        if (isCopyAction) {
          return (
            <Tooltip key={item.id}>
              <TooltipTrigger asChild>
                <button
                  type="button"
                  onClick={() => handleCopy(item)}
                  className="group block w-full border-b border-r border-dashed text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/20 focus-visible:ring-offset-0"
                >
                  {cellContent}
                </button>
              </TooltipTrigger>
              <TooltipContent>
                <p>
                  {isCopied ? "copied!" : item.tooltip ?? "copy"}
                  {item.shortcutKey ? <Kbd>{item.shortcutKey}</Kbd> : null}
                </p>
              </TooltipContent>
            </Tooltip>
          );
        }

        return (
          <Tooltip key={item.id}>
            <TooltipTrigger asChild>
              <a
                href={item.href ?? "#"}
                target="_blank"
                rel="noopener noreferrer"
                className="group block border-b border-r border-dashed focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/20 focus-visible:ring-offset-0"
              >
                {cellContent}
              </a>
            </TooltipTrigger>
            <TooltipContent>
              <p>{item.tooltip ?? item.platform}</p>
            </TooltipContent>
          </Tooltip>
        );
      })}
    </div>
  );
}