Discord Dot Presence

Squircle avatar with a live Discord status cutout.

⚠️: You must join the Lanyard Discord server for Lanyard to track and expose your live Discord presence to the API.

Pass any profile image via avatarSrc. The status cutout sits bottom-right with a ring that matches your surface (ringClassName).

Status changes pop in with a spring. Idle uses a moon glyph; online, DND, and offline are solid discs.

import DiscordDotPresence from "@/components/DiscordDotPresence";

export default function Profile() {
  return (
    <DiscordDotPresence
      avatarSrc="/your-photo.png"
      size={96}
      ringClassName="bg-background"
    />
  );
}

Install dependencies

npm install react-icons framer-motion

Source code

DiscordDotPresence.tsx
"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { IoMdMoon } from "react-icons/io";

export type DiscordPresence = "online" | "idle" | "dnd" | "offline";

interface StatusConfig {
  color: string;
  label: string;
}

const STATUS_MAP: Record<DiscordPresence, StatusConfig> = {
  online: {
    color: "bg-emerald-500",
    label: "Active now",
  },
  idle: {
    color: "bg-[#f0b232]",
    label: "Idle",
  },
  dnd: {
    color: "bg-red-500",
    label: "Do not disturb",
  },
  offline: {
    color: "bg-zinc-300 dark:bg-zinc-600",
    label: "Touching grass 🌿",
  },
};

export interface DiscordDotPresenceProps {
  /** Profile image */
  avatarSrc: string;
  avatarAlt?: string;
  status?: DiscordPresence;
  /** Poll endpoint. Defaults to /api/discord/presence */
  endpoint?: string;
  size?: number;
  pollInterval?: number;
  showTooltip?: boolean;
  className?: string;
  /** Cutout ring color should match the surface behind the avatar */
  ringClassName?: string;
}

const popTransition = {
  type: "spring" as const,
  duration: 0.28,
  bounce: 0.18,
};

export default function DiscordDotPresence({
  avatarSrc,
  avatarAlt = "Avatar",
  status: forcedStatus,
  endpoint = "/api/discord/presence",
  size = 96,
  pollInterval = 15_000,
  showTooltip = true,
  className,
  ringClassName = "bg-background",
}: DiscordDotPresenceProps) {
  const [status, setStatus] = useState<DiscordPresence>(
    forcedStatus ?? "offline"
  );

  useEffect(() => {
    if (forcedStatus) {
      setStatus(forcedStatus);
      return;
    }

    let cancelled = false;

    const fetchStatus = async () => {
      try {
        const res = await fetch(endpoint, { cache: "no-store" });
        if (!res.ok) throw new Error("failed");
        const json = await res.json();
        const next = json?.status as DiscordPresence;
        if (
          !cancelled &&
          (next === "online" ||
            next === "idle" ||
            next === "dnd" ||
            next === "offline")
        ) {
          setStatus(next);
        }
      } catch {
        if (!cancelled) setStatus("offline");
      }
    };

    fetchStatus();
    const id = window.setInterval(fetchStatus, pollInterval);
    return () => {
      cancelled = true;
      window.clearInterval(id);
    };
  }, [forcedStatus, endpoint, pollInterval]);

  const config = STATUS_MAP[status];
  const isIdle = status === "idle";
  const dotSize = Math.max(12, Math.round(size * 0.22));
  const cutoutPadding = Math.max(3, Math.round(size * 0.05));

  return (
    <div
      className={cn("group relative inline-flex shrink-0", className)}
      style={{ width: size, height: size }}
    >
      <img
        src={avatarSrc}
        alt={avatarAlt}
        width={size}
        height={size}
        className="size-full object-cover"
        style={{ borderRadius: Math.round(size * 0.28) }}
        draggable={false}
      />

      <div
        className={cn(
          "absolute bottom-0 right-0 z-20 flex items-center justify-center rounded-full",
          ringClassName
        )}
        style={{
          padding: cutoutPadding,
          transform: "translate(15%, 15%)",
        }}
      >
        <div className="relative flex items-center justify-center">
          {showTooltip ? (
            <div
              className={cn(
                "pointer-events-none absolute -top-10 left-1/2 z-50 -translate-x-1/2 whitespace-nowrap",
                "origin-bottom scale-95 opacity-0 transition-[opacity,transform] duration-150 ease-out",
                "group-hover:scale-100 group-hover:opacity-100"
              )}
            >
              <div className="rounded-md border border-border/60 bg-background px-2 py-1 text-[10px] font-medium text-foreground shadow-sm">
                {config.label}
              </div>
              <div className="absolute -bottom-1 left-1/2 size-1.5 -translate-x-1/2 rotate-45 border-b border-r border-border/60 bg-background" />
            </div>
          ) : null}

          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={status}
              initial={{ opacity: 0, scale: 0.45, y: 4 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.45, y: -4 }}
              transition={popTransition}
              className="relative flex items-center justify-center"
              style={{ width: dotSize, height: dotSize }}
              aria-label={config.label}
              role="status"
            >
              {isIdle ? (
                <span
                  className={cn(
                    "flex size-full items-center justify-center rounded-full text-background -rotate-[15deg]",
                    config.color
                  )}
                >
                  <IoMdMoon className="size-[85%]" aria-hidden />
                </span>
              ) : (
                <span className={cn("size-full rounded-full", config.color)} />
              )}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}

Source code

route.ts
import { NextRequest, NextResponse } from "next/server";

const SITE_URL = process.env.NEXT_PUBLIC_URL || "https://shahriaravi.me";

type RawStatus = "online" | "idle" | "dnd" | "offline";

let cachedPresence: { status: RawStatus; timestamp: number } | null = null;

export async function GET(request: NextRequest) {
  const accept = request.headers.get("accept") || "";
  const dest = request.headers.get("sec-fetch-dest") || "";
  const wantsHtml = dest === "document" || accept.includes("text/html");

  if (wantsHtml) {
    const html = `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>avi discord presence api</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    :root { color-scheme: dark; }
    body {
      margin: 0;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #111110;
      color: #e5e7eb;
      font-family: system-ui, -apple-system, sans-serif;
    }
    .wrap {
      padding: 1.75rem 2rem;
      border-radius: 1rem;
      background: rgba(255,255,255,0.03);
      border: 1px solid rgba(255,255,255,0.1);
      max-width: 480px;
    }
  </style>
</head>
<body>
  <div class="wrap">
    <p>Discord Presence API for <a href="${SITE_URL}">${SITE_URL}</a></p>
  </div>
</body>
</html>`;

    return new NextResponse(html, {
      status: 200,
      headers: {
        "Content-Type": "text/html; charset=utf-8",
        "Cache-Control": "public, max-age=3600",
      },
    });
  }

  const userId = process.env.NEXT_PUBLIC_DISCORD_USER_ID;
  if (!userId) {
    return NextResponse.json({ status: "offline" });
  }

  const now = Date.now();
  // Return cached result if less than 15 seconds old
  if (cachedPresence && now - cachedPresence.timestamp < 15000) {
    return NextResponse.json({ status: cachedPresence.status });
  }

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 3500);

  try {
    const res = await fetch(`https://api.lanyard.rest/v1/users/${userId}`, {
      signal: controller.signal,
      headers: { "User-Agent": "cooked-folio" },
      next: { revalidate: 10 },
    });
    clearTimeout(timeoutId);

    if (!res.ok) throw new Error(`Lanyard status ${res.status}`);

    const json = await res.json();
    const raw: RawStatus | undefined = json?.data?.discord_status;
    const status: RawStatus =
      raw === "online" || raw === "idle" || raw === "dnd" || raw === "offline"
        ? raw
        : "offline";

    cachedPresence = { status, timestamp: now };
    return NextResponse.json({ status });
  } catch (error) {
    clearTimeout(timeoutId);
    // Serve stale cache if fetch fails or times out
    if (cachedPresence) {
      return NextResponse.json({ status: cachedPresence.status });
    }
    return NextResponse.json({ status: "offline" });
  }
}