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-motionSource 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.tsimport { 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" });
}
}