Lantern
Directory Card.
The Lantern directory listing: a grid banner with an icon and hub address, category, title, description, tags and a status footer.
import { BookOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
DirectoryCard,
DirectoryCardArt,
DirectoryCardCategory,
DirectoryCardDescription,
DirectoryCardFooter,
DirectoryCardHeader,
DirectoryCardTags,
DirectoryCardTitle,
} from "@/components/ui/directory-card";
export default function DirectoryCardDemo() {
return (
<DirectoryCard asChild className="w-full max-w-sm">
<a href="#guestbook">
<DirectoryCardArt address="hub://guestbook/">
<BookOpen />
</DirectoryCardArt>
<DirectoryCardHeader>
<DirectoryCardCategory>Community</DirectoryCardCategory>
<DirectoryCardTitle>Lantern Guestbook</DirectoryCardTitle>
<DirectoryCardDescription>Leave a little warmth for the next traveler.</DirectoryCardDescription>
<DirectoryCardTags>
<Badge variant="outline">Lua</Badge>
<Badge variant="outline">Hub</Badge>
</DirectoryCardTags>
</DirectoryCardHeader>
<DirectoryCardFooter status="online">
Online
<span className="ml-auto">Computer 4127</span>
</DirectoryCardFooter>
</a>
</DirectoryCard>
);
}Installation#
With the CLI
$npx lanterncn add directory-cardThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/directory-card. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install radix-ui lucide-reactCopy the source into your project
components/ui/directory-card.tsximport * as React from "react"; import { Slot } from "radix-ui"; import { ArrowUpRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { CardArt } from "@/components/ui/card"; import { Eyebrow } from "@/components/ui/eyebrow"; import { StatusDot } from "@/components/ui/status-dot"; /** A Lantern directory listing. Use asChild to render it as a link. */ function DirectoryCard({ className, asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { const Comp = asChild ? Slot.Root : "div"; return ( <Comp data-slot="directory-card" className={cn( "group/directory-card relative flex flex-col overflow-hidden rounded-lg border bg-card text-card-foreground transition-[transform,border-color,box-shadow] outline-none", "[a&]:hover:-translate-y-1 [a&]:hover:border-input [a&]:hover:shadow-block-sm", "focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", className, )} {...props} /> ); } /** Grid banner with a large icon and a mono hub address in the corner. */ function DirectoryCardArt({ className, address, children, ...props }: React.ComponentProps<"div"> & { address?: React.ReactNode }) { return ( <CardArt data-slot="directory-card-art" className={cn("mt-0 h-36 sm:h-40", className)} {...props}> {children} {address != null && ( <span className="absolute bottom-3 left-4 max-w-[calc(100%-2rem)] truncate font-mono text-[9px] tracking-[0.15em] uppercase"> {address} </span> )} </CardArt> ); } function DirectoryCardHeader({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="directory-card-header" className={cn("flex flex-1 flex-col gap-1.5 px-5 pt-5 pb-5", className)} {...props} />; } function DirectoryCardCategory({ className, ...props }: React.ComponentProps<"div">) { return <Eyebrow data-slot="directory-card-category" className={className} {...props} />; } /** Card title with a trailing arrow that nudges on hover. */ function DirectoryCardTitle({ className, children, arrow = true, ...props }: React.ComponentProps<"div"> & { arrow?: boolean }) { return ( <div data-slot="directory-card-title" className={cn("flex items-start justify-between gap-3 font-display text-xl leading-tight font-medium tracking-tight", className)} {...props} > <span className="min-w-0">{children}</span> {arrow && ( <ArrowUpRight aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary transition-transform group-hover/directory-card:translate-x-0.5 group-hover/directory-card:-translate-y-0.5" /> )} </div> ); } function DirectoryCardDescription({ className, ...props }: React.ComponentProps<"p">) { return <p data-slot="directory-card-description" className={cn("text-sm leading-relaxed text-muted-foreground", className)} {...props} />; } function DirectoryCardTags({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="directory-card-tags" className={cn("mt-2 flex flex-wrap gap-1.5", className)} {...props} />; } /** Bottom row with a status dot and host details. */ function DirectoryCardFooter({ className, status, children, ...props }: React.ComponentProps<"div"> & { status?: "online" | "busy" | "offline" | "error" }) { return ( <div data-slot="directory-card-footer" className={cn( "mt-auto flex min-h-11 items-center gap-2.5 border-t px-5 py-3 font-mono text-[10px] tracking-[0.14em] text-muted-foreground uppercase", className, )} {...props} > {status && <StatusDot tone={status} pulse={status === "online"} />} {children} </div> ); } export { DirectoryCard, DirectoryCardArt, DirectoryCardHeader, DirectoryCardCategory, DirectoryCardTitle, DirectoryCardDescription, DirectoryCardTags, DirectoryCardFooter, };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand uses card, eyebrow, status-dot.
Usage#
import {
DirectoryCard,
DirectoryCardArt,
DirectoryCardHeader,
DirectoryCardCategory,
DirectoryCardTitle,
DirectoryCardDescription,
DirectoryCardTags,
DirectoryCardFooter,
} from "@/components/ui/directory-card";<DirectoryCard asChild>
<a href="/guestbook">
<DirectoryCardArt address="hub://guestbook/">
<BookOpen />
</DirectoryCardArt>
<DirectoryCardHeader>
<DirectoryCardCategory>Community</DirectoryCardCategory>
<DirectoryCardTitle>Lantern Guestbook</DirectoryCardTitle>
<DirectoryCardDescription>Leave a note.</DirectoryCardDescription>
</DirectoryCardHeader>
<DirectoryCardFooter status="online">Online</DirectoryCardFooter>
</a>
</DirectoryCard>With asChild the whole card becomes the link, and it lifts with a block shadow on hover. The footer status takes online, busy, offline or error.
Examples#
Responsive grid
hub://radio/
Media
Night Radio
Disc rotations streamed over rednet from the spawn tower.
AudioRednet
OnlineComputer 812
hub://turtles/
Tools
Turtle Yard
Queue mining jobs and watch fuel levels for the whole fleet.
LuaFleet
BusyComputer 2290
hub://market/
Trade
Ore Exchange
Post buy and sell orders for iron, gold and diamonds.
Shop
OfflineComputer 51
import { Bot, Pickaxe, Radio } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
DirectoryCard,
DirectoryCardArt,
DirectoryCardCategory,
DirectoryCardDescription,
DirectoryCardFooter,
DirectoryCardHeader,
DirectoryCardTags,
DirectoryCardTitle,
} from "@/components/ui/directory-card";
const sites = [
{
href: "#radio",
icon: Radio,
address: "hub://radio/",
category: "Media",
title: "Night Radio",
description: "Disc rotations streamed over rednet from the spawn tower.",
tags: ["Audio", "Rednet"],
status: "online" as const,
statusLabel: "Online",
host: "Computer 812",
},
{
href: "#turtles",
icon: Bot,
address: "hub://turtles/",
category: "Tools",
title: "Turtle Yard",
description: "Queue mining jobs and watch fuel levels for the whole fleet.",
tags: ["Lua", "Fleet"],
status: "busy" as const,
statusLabel: "Busy",
host: "Computer 2290",
},
{
href: "#market",
icon: Pickaxe,
address: "hub://market/",
category: "Trade",
title: "Ore Exchange",
description: "Post buy and sell orders for iron, gold and diamonds.",
tags: ["Shop"],
status: "offline" as const,
statusLabel: "Offline",
host: "Computer 51",
},
];
export default function DirectoryCardGrid() {
return (
<div className="@container w-full">
<div className="grid gap-4 @xl:grid-cols-2 @4xl:grid-cols-3">
{sites.map((site) => (
<DirectoryCard key={site.href} asChild>
<a href={site.href}>
<DirectoryCardArt address={site.address}>
<site.icon />
</DirectoryCardArt>
<DirectoryCardHeader>
<DirectoryCardCategory>{site.category}</DirectoryCardCategory>
<DirectoryCardTitle>{site.title}</DirectoryCardTitle>
<DirectoryCardDescription>{site.description}</DirectoryCardDescription>
<DirectoryCardTags>
{site.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</DirectoryCardTags>
</DirectoryCardHeader>
<DirectoryCardFooter status={site.status}>
{site.statusLabel}
<span className="ml-auto">{site.host}</span>
</DirectoryCardFooter>
</a>
</DirectoryCard>
))}
</div>
</div>
);
}