shadimage
akoder
uicontact-channelssource: ok · hit

Contact Channels

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

npx shadcn add "https://akoder.xyz/r/contact-channels.json"
free

Where it goes

contactkanban

Interaction

tooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Registry dependencies

tooltip

Files1 · source cached 10/15/2026, 4:40:26 AM

components/ui/contact-channels.tsx
"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>
  );
}

More from akoder

Browse all components in this library →