# Uptoolkit Full Context
> Design system for social projects.
This file expands the Markdown pages listed in https://ui.uptoolkit.com/llms.txt. Use https://ui.uptoolkit.com/registry.json for the machine-readable shadcn registry index.
---
URL: https://ui.uptoolkit.com/docs.md
Publish installable registry items with public docs from the same workspace.
# Introduction
Publish installable registry items with public docs from the same workspace.
This registry publishes copy-and-paste components, blocks, hooks, files, themes, styles, fonts, bases, and universal items that can be installed with the shadcn CLI.
Use the docs in this section to set up the registry, customize the site, and publish your own items.
Registry docs live in `registry/docs`. Installable source lives in `registry/items`. You can
update any of these files freely (or completely delete them!)
---
URL: https://ui.uptoolkit.com/docs/installation.md
Install and run the registry template locally.
# Installation
Install and run the registry template locally.
After forking the template, install dependencies with [Vite+](https://viteplus.dev/guide/) and start the local docs app.
```sh
vp install
vp dev
```
Open the local URL from Vite+ to preview your docs, registry catalog, and install pages.
## Customize the Registry
Update `registry/config.ts` before publishing your fork. This file controls the public registry identity, namespace, description, homepage, repository URL, and generated install URLs.
## Add Registry Items
Scaffold new installable items under `registry/items/**` with the starter script.
```sh
bun --bun ./scripts/new.ts --type registry:ui --name example-card --description "A compact card component."
```
You can also run `bun --bun ./scripts/new.ts` for an interactive prompt. Use `--target` for `registry:page`, `registry:file`, custom alias installs such as `@ui/ai/prompt-input.tsx`, or targeted `registry:item` files.
The scaffold creates the item source, `_registry.mdx` metadata and usage docs, and an optional `_preview.tsx` preview. Public documentation pages live directly in `registry/docs/`.
## Verify Changes
Run the registry doctor after item changes, check the files you touched, and build before publishing.
```sh
bun --bun ./scripts/doctor.ts
vp check --fix
vp build
```
---
URL: https://ui.uptoolkit.com/docs/registry.md
Scaffold and author installable registry items under registry/items.
# Registry
Scaffold and author installable registry items under registry/items.
Registry items live under `registry/items/**` and can be installed by shadcn-compatible CLIs. Start new items with the scaffold command so the folder, `_registry.mdx`, source file, metadata, and generated registry JSON paths follow the template conventions.
```sh
bun --bun ./scripts/new.ts --type registry:ui --name example-card --description "A compact card component."
```
Or, run the command without flags if you want an interactive prompt (recommended).
```sh
bun --bun ./scripts/new.ts
```
[Make sure Bun is installed before running these scripts.](https://bun.com/)
## Item Types
Choose the registry type from what the item installs:
| Type | Use For | Folder |
| -------------------- | ----------------------------------------- | ------------------------------ |
| `registry:ui` | Shadcn-style UI components | `registry/items/components/**` |
| `registry:component` | Components outside `components/ui` | `registry/items/components/**` |
| `registry:block` | Larger composed UI patterns | `registry/items/blocks/**` |
| `registry:hook` | React hooks | `registry/items/hooks/**` |
| `registry:lib` | Utility or helper modules | `registry/items/lib/**` |
| `registry:page` | App page files with explicit targets | `registry/items/pages/**` |
| `registry:file` | Other files with explicit install targets | `registry/items/files/**` |
| `registry:style` | Style-level CSS and dependency metadata | `registry/items/styles/**` |
| `registry:theme` | Theme CSS variables | `registry/items/themes/**` |
| `registry:font` | Font metadata | `registry/items/fonts/**` |
| `registry:base` | Design system base configuration | `registry/items/bases/**` |
| `registry:item` | Universal or metadata-only items | `registry/items/items/**` |
The scaffold creates each item in its own folder:
```text
registry/items/components/example-card/
_registry.mdx
_preview.tsx
example-card.tsx
```
## CLI Flags
Use flags for agent-friendly and CI-friendly scaffolding:
```sh
bun --bun ./scripts/new.ts --type registry:block --name stats-panel --description "A metrics panel with reusable sample data."
bun --bun ./scripts/new.ts --type registry:ui --name prompt-input --description "An AI prompt input." --target @ui/ai/prompt-input.tsx
bun --bun ./scripts/new.ts --type registry:page --name dashboard-page --description "A starter dashboard page." --target app/dashboard/page.tsx
bun --bun ./scripts/new.ts --type registry:file --name chart-theme --description "Shared chart theme tokens." --target styles/chart-theme.css --file-extension css
bun --bun ./scripts/new.ts --type registry:font --name font-inter --description "Inter font metadata." --font-family "'Inter Variable', sans-serif" --font-import Inter --font-variable=--font-sans
```
Supported flags:
- `--type`: registry item type. Defaults to `registry:ui`.
- `--name`: required kebab-case item name.
- `--title`: optional public title. Defaults from the name.
- `--description`: required public description.
- `--target`: required for `registry:page` and `registry:file`; optional for source-backed items that need a custom install path, including shadcn target placeholders like `@ui/ai/prompt-input.tsx`.
- `--file-extension`: for `registry:file` and targeted `registry:item`; defaults to `ts`.
- `--font-family`, `--font-import`, `--font-variable`: required for noninteractive `registry:font` scaffolds.
Use `bun --bun ./scripts/new.ts --help` to print the full usage.
## Authoring
Use `_registry.mdx` for public metadata and usage docs. Put the optional named `Preview` export in `_preview.tsx`. Keep both files private to authoring; never list them in `files`.
For one-file `registry:ui` items, the catalog infers `.tsx`. Hooks, libs, blocks, pages, target paths, and multi-file items should list `files` explicitly. Metadata-only styles, themes, fonts, bases, and universal items can omit `files`.
List authored source files with paths relative to the item `_registry.mdx` file. The catalog automatically emits `files[].target` placeholders such as `@ui/.tsx`, `@components/.tsx`, `@hooks/.ts`, and `@lib/.ts` for file types that should install through the user's `components.json` aliases. Use explicit `target` values for `registry:page`, `registry:file`, and nested alias installs like `@ui/ai/prompt-input.tsx`; do not add `registry/items/**` prefixes or a separate `sourcePath` field.
Use `localRegistryDependencies` for dependencies on other local registry items.
After editing an item, run `vp check --fix` on touched files and `bun --bun ./scripts/doctor.ts`. Run `vp build` when registry docs, routes, JSON output, catalog loading, or source loading changed.
---
URL: https://ui.uptoolkit.com/docs/agents.md
Give AI coding tools the right context for using and authoring this registry.
# Agents
Give AI coding tools the right context for using and authoring this registry.
This template is designed to work well with coding agents and LLM-powered tools. It exposes Markdown-first routes for reading context, and it ships an installable Agent Skill for authoring registry items correctly.
## Install the Registry Skill
Install the `_cn` registry authoring skill from the upstream template:
```sh
npx skills add jakejarvis/_cn --skill shadcn-registry
```
Forks can install the same skill from their own repository:
```sh
npx skills add / --skill shadcn-registry
```
After installing the skill, ask for registry work directly:
- "add a button component to the registry"
- "adapt this modal from my app into a reusable registry component"
- "add a reusable hook to the registry"
- "turn this dashboard section into a registry block"
The skill covers all public registry item types, previews, usage docs, dependency metadata, and the scaffold command for creating new items non-interactively.
## Point Agents At Markdown
The generated [`/llms.txt`](/llms.txt) route gives AI tools a compact map of the docs, registry catalog, and item pages.
Use [`/llms-full.txt`](/llms-full.txt) when a tool needs expanded context with generated Markdown content inlined.
Every docs page, the registry catalog, and each registry item also has a Markdown route:
- [`/docs.md`](/docs.md)
- [`/docs/agents.md`](/docs/agents.md)
- [`/registry.md`](/registry.md)
- [`/components/example-card.md`](/components/example-card.md)
Inspired by [Fumadocs](https://www.fumadocs.dev/docs/headless/utils/negotiation), "human" permalinks support Markdown content negotiation too. AI clients can request `text/markdown`, `text/x-markdown`, or `text/plain` in the `Accept` header on pages like [`/docs`](/docs) or [`/components/example-card`](/components/example-card) and receive Markdown without changing URLs.
## Recommended Agent Workflow
For registry authoring tasks, agents should:
1. Read `AGENTS.md`, this docs section, and `/llms.txt` or `/llms-full.txt` for project context.
2. Use the `shadcn-registry` skill when available.
3. Scaffold new items with `bun --bun ./scripts/new.ts --type --name --description ""`.
4. Add `--target` for `registry:page`, `registry:file`, custom alias installs like `@ui/ai/.tsx`, and targeted `registry:item` files; use `--file-extension` for `registry:file` and targeted `registry:item` files.
5. Edit the generated source, `_registry.mdx`, and `_preview.tsx` with usage docs, previews, and dependency metadata.
6. Run `vp check --fix` on touched files, `bun --bun ./scripts/doctor.ts`, and `vp build` before handoff when docs, routes, registry JSON, catalog loading, or source loading changed.
---
URL: https://ui.uptoolkit.com/docs/cli.md
Install items from this registry with the shadcn CLI.
# CLI
Install items from this registry with the shadcn CLI.
Install a registry item with the command shown on its docs page.
```sh
npx shadcn@latest add https://ui.uptoolkit.com/r/comment-thread.json
```
The command that's displayed uses the `homepage` value from `registry/config.ts` for the base URL.
Human-facing item pages also work with the shadcn CLI through content negotiation. For example, a browser can view [`/components/comment-thread`](/components/comment-thread), while the CLI receives the installable item JSON from the same URL:
```sh
npx shadcn@latest add https://ui.uptoolkit.com/components/comment-thread
```
Use the package manager selector on item pages to switch between npm, pnpm, yarn, bun, vite+, and deno commands.
---
URL: https://ui.uptoolkit.com/docs/theming.md
Customize the docs shell and published components with your own design tokens.
# Theming
Customize the docs shell and published components with your own design tokens.
The docs app uses the local shadcn configuration in `components.json` and the global theme in `src/styles.css`.
Published registry items are independent from the docs shell. Put installable component source in `registry/items/**`, and include any required CSS, dependencies, or registry dependencies in the item frontmatter.
Use semantic tokens such as `background`, `foreground`, `primary`, `muted`, and `border` so installed components remain easy to customize.
---
URL: https://ui.uptoolkit.com/docs/changelog.md
Track user-facing changes to this registry.
# Changelog
Track user-facing changes to this registry.
Use this page to publish notable registry updates, new items, breaking changes, and migration notes.
## v1.0.1: April 21, 2026
- Fixed the pointer cursor when hovering `Button` components.
---
URL: https://ui.uptoolkit.com/components.md
Installable UI primitives and components.
# Components
Installable UI primitives and components.
- [Actor Avatar](https://ui.uptoolkit.com/components/actor-avatar): Avatar for an ActivityPub actor with initials fallback and presence badge.
- [Actor Card](https://ui.uptoolkit.com/components/actor-card): Profile summary card for an ActivityPub actor with Schema.org Person markup.
- [Comment Thread](https://ui.uptoolkit.com/components/comment-thread): Nested reply thread with Schema.org Comment markup and an inline composer.
- [Example Card](https://ui.uptoolkit.com/components/example-card): A compact card component that demonstrates shadcn registry dependencies.
- [Follow Button](https://ui.uptoolkit.com/components/follow-button): Follow control that emits ActivityPub Follow and Undo activities.
- [JSON-LD](https://ui.uptoolkit.com/components/json-ld): Renders a safely serialized application/ld+json script tag.
- [Post Card](https://ui.uptoolkit.com/components/post-card): Renders an ActivityPub Note or Article with Schema.org SocialMediaPosting markup.
- [Post Composer](https://ui.uptoolkit.com/components/post-composer): Composer that emits an ActivityPub Create activity with audience targeting.
- [Reaction Bar](https://ui.uptoolkit.com/components/reaction-bar): Like, boost, reply, and share bar that emits ActivityPub activities.
---
URL: https://ui.uptoolkit.com/components/actor-avatar.md
Avatar for an ActivityPub actor with initials fallback and presence badge.
# Actor Avatar
Avatar for an ActivityPub actor with initials fallback and presence badge.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/actor-avatar.json
```
[Registry JSON](https://ui.uptoolkit.com/r/actor-avatar.json)
## Preview
```tsx
import {
sampleAuthor,
sampleFollowers,
sampleViewer,
} from "@/lib/social-sample-data";
import { ActorAvatar, ActorAvatarGroup } from "@/components/ui/actor-avatar";
export function Preview() {
return (
);
}
```
## Source
### ui/actor-avatar.tsx
```tsx
import { IconLock, IconRosetteDiscountCheckFilled } from "@tabler/icons-react";
import * as React from "react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import {
getActorDisplayName,
getActorHandle,
getActorInitials,
type ActivityPubActor,
} from "@/lib/activitypub";
type ActorPresence = "online" | "away" | "offline";
const presenceStyles: Record, string> = {
online: "bg-emerald-500",
away: "bg-amber-500",
};
type ActorAvatarProps = Omit, "children"> & {
actor: ActivityPubActor;
size?: "sm" | "default" | "lg";
/** Renders the coloured presence dot. `offline` renders nothing. */
presence?: ActorPresence;
/** Shows a check badge, for a verified `rel="me"` link or local account. */
verified?: boolean;
/** Shows a lock badge when the actor manually approves followers. */
showLock?: boolean;
};
/**
* Avatar for an ActivityPub actor.
*
* The image comes from the actor's `icon`, with initials from `name` or
* `preferredUsername` as the fallback. `alt` is left empty and the handle is
* exposed through `aria-label` so screen readers announce the actor once.
*/
function ActorAvatar({
actor,
size = "default",
presence = "offline",
verified = false,
showLock = false,
className,
...props
}: ActorAvatarProps) {
const displayName = getActorDisplayName(actor);
const lock = showLock && actor.manuallyApprovesFollowers;
return (
{actor.icon?.url ? : null}
{getActorInitials(actor)}
{verified ? (
) : lock ? (
) : presence !== "offline" ? (
) : null}
);
}
type ActorAvatarGroupProps = React.ComponentProps & {
actors: readonly ActivityPubActor[];
size?: "sm" | "default" | "lg";
/** Avatars rendered before collapsing the rest into a `+n` chip. */
max?: number;
/** Total participants, when it exceeds the actors you were able to load. */
total?: number;
};
/** Overlapping avatars for reaction, participant, and mutual-friend rows. */
function ActorAvatarGroup({
actors,
size = "sm",
max = 4,
total,
className,
...props
}: ActorAvatarGroupProps) {
const visible = actors.slice(0, max);
const remainder = (total ?? actors.length) - visible.length;
return (
{visible.map((actor) => (
))}
{remainder > 0 ? (
+{remainder}
) : null}
);
}
export {
ActorAvatar,
ActorAvatarGroup,
type ActorAvatarGroupProps,
type ActorAvatarProps,
type ActorPresence,
};
```
## Usage
Takes a whole `ActivityPubActor` instead of a URL and a name, so it reads the actor's `icon` for the
image and falls back to initials from `name` or `preferredUsername`.
```tsx
import { ActorAvatar, ActorAvatarGroup } from "@/components/ui/actor-avatar";
{/* when manuallyApprovesFollowers */}
```
### Badges
One badge renders at a time, in priority order: `verified`, then `showLock` (only when the actor sets
`manuallyApprovesFollowers`), then `presence`. Pass `presence="offline"` — the default — for no dot.
### Groups
`ActorAvatarGroup` overlaps avatars and collapses the rest into a `+n` chip. Pass `total` when the
real participant count is larger than the actors you were able to load, which is the normal case for
a paginated `likes` or `followers` collection.
```tsx
```
### Accessibility
The image `alt` is intentionally empty and the display name plus handle go on the root's
`aria-label`, so a screen reader announces the actor once rather than twice. The root also carries
`itemProp="image"`, so it slots into a parent Schema.org `Person` scope without extra markup.
---
URL: https://ui.uptoolkit.com/components/actor-card.md
Profile summary card for an ActivityPub actor with Schema.org Person markup.
# Actor Card
Profile summary card for an ActivityPub actor with Schema.org Person markup.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/actor-card.json
```
[Registry JSON](https://ui.uptoolkit.com/r/actor-card.json)
## Preview
```tsx
import {
sampleAuthor,
sampleCounts,
sampleFollowers,
sampleViewer,
} from "@/lib/social-sample-data";
import { ActorCard } from "@/components/ui/actor-card";
export function Preview() {
return (
undefined}
/>
);
}
```
## Source
### ui/actor-card.tsx
```tsx
"use client";
import { IconCalendar, IconLink, IconMessage } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
formatCompactNumber,
formatPublishedDate,
getActorDisplayName,
getActorHandle,
toPlainText,
type ActivityPubActor,
} from "@/lib/activitypub";
import { toPersonJsonLd, type SocialInteractionCounts } from "@/lib/schema-org";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { FollowButton, type FollowButtonProps } from "@/components/ui/follow-button";
import { JsonLd } from "@/components/json-ld";
type ActorCardProps = Omit, "children"> & {
actor: ActivityPubActor;
/** The signed-in actor, forwarded to the follow button. */
viewer?: ActivityPubActor;
/** Follower, following, and post counts shown in the stats row. */
counts?: SocialInteractionCounts;
followState?: FollowButtonProps["state"];
defaultFollowState?: FollowButtonProps["defaultState"];
onFollow?: FollowButtonProps["onFollow"];
onUnfollow?: FollowButtonProps["onUnfollow"];
onFollowStateChange?: FollowButtonProps["onStateChange"];
onMessage?: (actor: ActivityPubActor) => void;
/** `card` stacks the profile; `row` is a compact list item. */
variant?: "card" | "row";
/** Show the actor's `attachment` PropertyValue metadata rows. */
showFields?: boolean;
/** Emits a Schema.org `Person` (or `Organization`) JSON-LD script. */
includeJsonLd?: boolean;
hideFollow?: boolean;
/** BCP 47 locale for dates and counts. Must match on server and client. */
locale?: string;
};
function ActorStats({ counts, locale }: { counts: SocialInteractionCounts; locale?: string }) {
const entries: [string, number | undefined][] = [
["Posts", counts.posts],
["Followers", counts.followers],
["Following", counts.following],
];
const visible = entries.filter((entry): entry is [string, number] => entry[1] !== undefined);
if (visible.length === 0) {
return null;
}
return (
{visible.map(([label, value]) => (
{label}
{formatCompactNumber(value, locale)}
))}
);
}
/**
* Profile summary card for an ActivityPub actor.
*
* Renders the actor's identity, bio, verified profile fields, and counts, with
* Schema.org `Person`/`Organization` microdata inline. Verification comes from
* the `verifiedAt` flag on the actor's `attachment` fields, matching how
* Mastodon marks a confirmed `rel="me"` link.
*/
function ActorCard({
actor,
viewer,
counts,
followState,
defaultFollowState,
onFollow,
onUnfollow,
onFollowStateChange,
onMessage,
variant = "card",
showFields = false,
includeJsonLd = false,
hideFollow = false,
locale,
className,
...props
}: ActorCardProps) {
const isRow = variant === "row";
const bio = toPlainText(actor.summary);
const joined = formatPublishedDate(actor.published, locale === undefined ? {} : { locale });
const isOrganization = actor.type === "Organization" || actor.type === "Group";
const fields = showFields ? (actor.attachment ?? []) : [];
const verified = (actor.attachment ?? []).some((field) => Boolean(field.verifiedAt));
const follow = hideFollow ? null : (
);
return (
{includeJsonLd ? : null}
: null}
{isRow ? null : (
{follow}
{onMessage ? (
) : null}
)}
);
}
export { ActorCard, type ActorCardProps };
```
## Usage
Profile summary for an ActivityPub actor: identity, bio, counts, profile fields, and a follow
control. Use it in follower lists, suggestion rails, and search results.
```tsx
import { ActorCard } from "@/components/ui/actor-card";
openThread(actor)}
/>
```
### Variants
`card` stacks avatar, identity, and actions vertically. `row` is a compact list item with the follow
button trailing — the right shape for a sidebar or a long list.
```tsx
```
### Verification
`showFields` renders the actor's `attachment` PropertyValue rows. Fields with `verifiedAt` set — how
Mastodon marks a confirmed `rel="me"` link — get a link icon and a verified accent, and the avatar
picks up a check badge.
### Structured data
The card emits `Person` or `Organization` microdata depending on the actor's `type`, including the
WebFinger handle as `identifier`. Add `includeJsonLd` for a JSON-LD script; leave it off when the
card is inside a list that already emits an `ItemList`, to avoid duplicate nodes.
Follow state is controlled or uncontrolled, forwarding to
[`follow-button`](/components/follow-button) — pass `hideFollow` to drop the control entirely.
---
URL: https://ui.uptoolkit.com/components/comment-thread.md
Nested reply thread with Schema.org Comment markup and an inline composer.
# Comment Thread
Nested reply thread with Schema.org Comment markup and an inline composer.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/comment-thread.json
```
[Registry JSON](https://ui.uptoolkit.com/r/comment-thread.json)
## Preview
```tsx
import {
SAMPLE_NOW,
samplePost,
sampleReplies,
sampleViewer,
} from "@/lib/social-sample-data";
import { CommentThread } from "@/components/ui/comment-thread";
export function Preview() {
return (
);
}
```
## Source
### ui/comment-thread.tsx
```tsx
"use client";
import { IconCornerDownRight, IconHeart, IconHeartFilled } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
createLikeActivity,
createNoteActivity,
createUndoActivity,
formatCompactNumber,
formatPublishedTime,
getActorDisplayName,
getActorHandle,
getCollectionCount,
getCollectionItems,
resolveActor,
toPlainText,
type ActivityPubActivity,
type ActivityPubActor,
type ActivityPubCollection,
type ActivityPubObject,
} from "@/lib/activitypub";
import { toCommentJsonLd } from "@/lib/schema-org";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { JsonLd } from "@/components/json-ld";
type CommentNode = {
object: ActivityPubObject;
children: CommentNode[];
};
/** Oldest first, matching how conversations read. */
function byPublished(a: CommentNode, b: CommentNode): number {
return Date.parse(a.object.published ?? "") - Date.parse(b.object.published ?? "");
}
/**
* Builds a reply tree from a flat list using `inReplyTo`.
*
* Replies whose parent is missing from the list are attached at the root so
* nothing is silently dropped from a partially loaded conversation.
*/
function buildCommentTree(
replies: readonly ActivityPubObject[],
rootId: string | undefined,
): CommentNode[] {
const nodes = new Map(
replies.map((object) => [object.id, { object, children: [] }]),
);
const roots: CommentNode[] = [];
for (const object of replies) {
const node = nodes.get(object.id);
if (!node) {
continue;
}
const parentId = object.inReplyTo ?? undefined;
const parent = parentId && parentId !== rootId ? nodes.get(parentId) : undefined;
if (parent && parent !== node) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const sort = (list: CommentNode[]) => {
list.sort(byPublished);
for (const node of list) {
sort(node.children);
}
};
sort(roots);
return roots;
}
type CommentThreadProps = Omit, "children"> & {
/** The post the replies belong to. */
object: ActivityPubObject;
/** Replies. Defaults to the object's own `replies` collection. */
replies?: ActivityPubCollection | readonly ActivityPubObject[];
/** The signed-in actor. Required to compose or like a reply. */
viewer?: ActivityPubActor;
/** Nesting levels before replies are flattened. */
maxDepth?: number;
/** Root replies shown before the "view more" control. */
initialCount?: number;
onReply?: (activity: ActivityPubActivity) => void | Promise;
onLike?: (activity: ActivityPubActivity) => void;
onUnlike?: (activity: ActivityPubActivity) => void;
/** Emits Schema.org `Comment` JSON-LD for the whole thread. */
includeJsonLd?: boolean;
/** Fixed "now" for relative timestamps. */
now?: Date | number;
/** BCP 47 locale for dates and counts. Must match on server and client. */
locale?: string;
/** Hide the composer, for example on a read-only permalink. */
hideComposer?: boolean;
};
type CommentProps = {
node: CommentNode;
depth: number;
maxDepth: number;
viewer?: ActivityPubActor;
now?: Date | number;
locale?: string;
onSubmitReply: (parent: ActivityPubObject, content: string) => Promise;
onLike?: CommentThreadProps["onLike"];
onUnlike?: CommentThreadProps["onUnlike"];
};
function Comment({
node,
depth,
maxDepth,
viewer,
now,
locale,
onSubmitReply,
onLike,
onUnlike,
}: CommentProps) {
const [replyOpen, setReplyOpen] = React.useState(false);
const [childrenOpen, setChildrenOpen] = React.useState(depth < 1);
const [liked, setLiked] = React.useState(false);
const likeActivityRef = React.useRef(null);
const { object } = node;
const author = resolveActor(object.attributedTo);
const likeCount = getCollectionCount(object.likes) + (liked ? 1 : 0);
const nested = depth + 1 <= maxDepth;
const handleLike = () => {
const next = !liked;
setLiked(next);
if (!viewer) {
return;
}
if (next) {
const activity = createLikeActivity({ actor: viewer, object });
likeActivityRef.current = activity;
onLike?.(activity);
return;
}
if (likeActivityRef.current) {
onUnlike?.(createUndoActivity({ actor: viewer, activity: likeActivityRef.current }));
likeActivityRef.current = null;
}
};
return (
);
}
export { CommentThread, buildCommentTree, type CommentNode, type CommentThreadProps };
```
## Usage
Builds a nested conversation from a flat list of replies and lets the viewer add to it.
```tsx
import { CommentThread } from "@/components/ui/comment-thread";
postToOutbox(viewer, activity)}
/>
```
Replies default to the object's own `replies` collection, so a post fetched from an outbox works
without a second prop.
### Threading
`buildCommentTree` groups replies by `inReplyTo`. Replies whose parent is not in the list are
attached at the root rather than dropped, so a partially loaded conversation still renders in full.
Nesting stops at `maxDepth` (default `3`) and deeper replies flatten into the last level.
```ts
import { buildCommentTree } from "@/components/ui/comment-thread";
const roots = buildCommentTree(replies, note.id);
```
### Replying
New replies are emitted as `Create` activities carrying `inReplyTo`, and appear immediately while
`onReply` settles. Root replies beyond `initialCount` collapse behind a "view more" control, and
nested replies collapse behind "view N replies".
### Structured data
Each comment carries Schema.org `Comment` microdata with `author`, `text`, `datePublished`, and
`parentItem`. Add `includeJsonLd` to emit the thread as JSON-LD as well.
Pass `hideComposer` for a read-only permalink view. Omitting `viewer` also hides it, since there is
no actor to attribute a reply to.
---
URL: https://ui.uptoolkit.com/components/example-card.md
A compact card component that demonstrates shadcn registry dependencies.
# Example Card
A compact card component that demonstrates shadcn registry dependencies.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/example-card.json
```
[Registry JSON](https://ui.uptoolkit.com/r/example-card.json)
## Preview
```tsx
import { ExampleCard } from "@/components/ui/example-card";
export function Preview() {
return ;
}
```
## Source
### ui/example-card.tsx
```tsx
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type ExampleCardProps = {
title?: string;
description?: string;
status?: string;
};
function ExampleCard({
title = "Ready to publish",
description = "Use this component as a small, installable starter item.",
status = "Template",
}: ExampleCardProps) {
return (
{title}{status}
{description}
Replace this example with a component from your own registry.
);
}
export { ExampleCard, type ExampleCardProps };
```
## Usage
Use the card anywhere you need a compact content summary with a title, description, status badge,
and action.
```tsx
import { ExampleCard } from "@/components/ui/example-card";
```
---
URL: https://ui.uptoolkit.com/components/follow-button.md
Follow control that emits ActivityPub Follow and Undo activities.
# Follow Button
Follow control that emits ActivityPub Follow and Undo activities.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/follow-button.json
```
[Registry JSON](https://ui.uptoolkit.com/r/follow-button.json)
## Preview
```tsx
import * as React from "react";
import { getActorHandle } from "@/lib/activitypub";
import {
sampleAuthor,
sampleFollowers,
sampleViewer,
} from "@/lib/social-sample-data";
import { FollowButton, type FollowState } from "@/components/ui/follow-button";
const targets = [sampleAuthor, sampleFollowers[2]];
export function Preview() {
const [log, setLog] = React.useState([]);
return (
{log[0] ?? "Follow an actor to emit an activity."}
);
}
```
## Source
### ui/follow-button.tsx
```tsx
"use client";
import { IconCheck, IconClock, IconPlus, IconUserMinus } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
createFollowActivity,
createUndoActivity,
getActorDisplayName,
type ActivityPubActivity,
type ActivityPubActor,
} from "@/lib/activitypub";
/** Mirrors the ActivityPub follow lifecycle: Follow -> Accept, or Undo. */
type FollowState = "none" | "requested" | "following";
type FollowButtonProps = Omit<
React.ComponentProps,
"children" | "onClick" | "value" | "defaultValue"
> & {
/** The actor being followed. */
actor: ActivityPubActor;
/** The signed-in actor. Required to build a deliverable activity. */
viewer?: ActivityPubActor;
/** Controlled state. Omit to let the button manage its own state. */
state?: FollowState;
defaultState?: FollowState;
onStateChange?: (state: FollowState) => void;
/**
* Called with the `Follow` activity to POST to the viewer's outbox. Resolve to
* `false` to roll the optimistic state back.
*/
onFollow?: (activity: ActivityPubActivity) => void | boolean | Promise;
/** Called with the `Undo` activity that retracts the follow. */
onUnfollow?: (
activity: ActivityPubActivity>,
) => void | boolean | Promise;
/** Swaps the following label for "Unfollow" on hover and focus. */
showUnfollowOnHover?: boolean;
};
const stateLabels: Record = {
none: "Follow",
requested: "Requested",
following: "Following",
};
/**
* Follow control for an ActivityPub actor.
*
* Follows are optimistic: the label updates immediately, then reverts if the
* handler resolves to `false`. Actors with `manuallyApprovesFollowers` move to
* `requested` instead of `following`, matching the server's Accept flow.
*/
function FollowButton({
actor,
viewer,
state: controlledState,
defaultState = "none",
onStateChange,
onFollow,
onUnfollow,
showUnfollowOnHover = true,
className,
disabled,
size = "sm",
...props
}: FollowButtonProps) {
const [uncontrolledState, setUncontrolledState] = React.useState(defaultState);
const [pending, setPending] = React.useState(false);
const [intentVisible, setIntentVisible] = React.useState(false);
const state = controlledState ?? uncontrolledState;
const isFollowing = state === "following" || state === "requested";
/** Track the sent Follow so Undo can reference the exact activity id. */
const followActivityRef = React.useRef | null>(null);
const commitState = React.useCallback(
(next: FollowState) => {
if (controlledState === undefined) {
setUncontrolledState(next);
}
onStateChange?.(next);
},
[controlledState, onStateChange],
);
const handleClick = React.useCallback(async () => {
if (pending) {
return;
}
const previous = state;
const next: FollowState = isFollowing
? "none"
: actor.manuallyApprovesFollowers
? "requested"
: "following";
commitState(next);
setPending(true);
try {
if (!viewer) {
return;
}
if (isFollowing) {
const follow =
followActivityRef.current ?? createFollowActivity({ actor: viewer, object: actor });
const result = await onUnfollow?.(createUndoActivity({ actor: viewer, activity: follow }));
followActivityRef.current = null;
if (result === false) {
commitState(previous);
}
return;
}
const follow = createFollowActivity({ actor: viewer, object: actor });
followActivityRef.current = follow;
const result = await onFollow?.(follow);
if (result === false) {
followActivityRef.current = null;
commitState(previous);
}
} finally {
setPending(false);
}
}, [actor, commitState, isFollowing, onFollow, onUnfollow, pending, state, viewer]);
const showUnfollowIntent = showUnfollowOnHover && isFollowing && intentVisible;
const label = showUnfollowIntent ? "Unfollow" : stateLabels[state];
const Icon = showUnfollowIntent
? IconUserMinus
: state === "following"
? IconCheck
: state === "requested"
? IconClock
: IconPlus;
return (
);
}
export { FollowButton, type FollowButtonProps, type FollowState };
```
## Usage
Emits real activities rather than calling an opaque callback: `onFollow` receives a `Follow` and
`onUnfollow` receives an `Undo` wrapping the exact `Follow` that was sent, which is what an
ActivityPub server needs to retract the relationship.
```tsx
import { FollowButton } from "@/components/ui/follow-button";
postToOutbox(viewer, activity)}
onUnfollow={(activity) => postToOutbox(viewer, activity)}
/>
```
### Optimistic state
The label changes immediately. Return `false` from either handler to roll it back:
```tsx
{
const response = await postToOutbox(viewer, activity);
return response.ok;
}}
/>
```
### Follow requests
Actors with `manuallyApprovesFollowers` go to `requested` instead of `following`, matching the
server's pending `Accept`. Once the `Accept` arrives, drive the button as controlled:
```tsx
```
Hovering or focusing a followed actor swaps the label to "Unfollow"; pass
`showUnfollowOnHover={false}` to keep it static. `aria-pressed` always reflects the relationship.
---
URL: https://ui.uptoolkit.com/components/json-ld.md
Renders a safely serialized application/ld+json script tag.
# JSON-LD
Renders a safely serialized application/ld+json script tag.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/json-ld.json
```
[Registry JSON](https://ui.uptoolkit.com/r/json-ld.json)
## Preview
```tsx
import { JsonLd, serializeJsonLd } from "@/components/json-ld";
const data = {
"@context": "https://schema.org",
"@type": "SocialMediaPosting",
"@id": "https://social.example/users/ada/posts/1",
text: "Structured data ships with the markup ",
author: { "@type": "Person", name: "Ada Okoye" },
};
export function Preview() {
return (
The script tag is in the document. Its escaped contents:
{serializeJsonLd(data, 2)}
);
}
```
## Source
### components/json-ld.tsx
```tsx
/**
* Renders JSON-LD structured data as an `application/ld+json` script tag.
*
* @see https://json-ld.org/spec/latest/json-ld/#embedding-json-ld-in-html-documents
*/
/** Kept as named constants so the source file stays pure ASCII. */
const LINE_SEPARATOR = "\u2028";
const PARAGRAPH_SEPARATOR = "\u2029";
const scriptEscapes: Record = {
"<": "\\u003c",
">": "\\u003e",
"&": "\\u0026",
[LINE_SEPARATOR]: "\\u2028",
[PARAGRAPH_SEPARATOR]: "\\u2029",
};
const unsafeScriptCharacters = /[<>&\u2028\u2029]/gu;
function escapeJsonForScript(json: string): string {
return json.replace(unsafeScriptCharacters, (char) => scriptEscapes[char] ?? char);
}
/**
* Serializes JSON-LD for inline embedding.
*
* `<`, `>`, and `&` are escaped so a value containing `` cannot close
* the tag early, and U+2028/U+2029 are escaped because they are valid in JSON
* but illegal raw inside a JavaScript string literal.
*/
function serializeJsonLd(data: unknown, space?: number): string {
return escapeJsonForScript(JSON.stringify(data, null, space) ?? "null");
}
type JsonLdProps = {
/** A single node, or several nodes rendered as a JSON-LD array. */
data: unknown;
/** Optional `id` so a framework can dedupe or replace the tag. */
id?: string;
/** Pretty-print with the given indent. Useful while debugging. */
space?: number;
};
function JsonLd({ data, id, space }: JsonLdProps) {
if (data === null || data === undefined) {
return null;
}
if (Array.isArray(data) && data.length === 0) {
return null;
}
return (
);
}
export { JsonLd, serializeJsonLd, type JsonLdProps };
```
## Usage
Emits structured data as an `application/ld+json` script tag. Works in a server component, a client
component, or anywhere else you can render an element.
```tsx
import { JsonLd } from "@/components/json-ld";
```
Pass an array to emit several nodes at once. `null`, `undefined`, and empty arrays render nothing, so
you can hand it optional data without guarding the call site.
### Escaping
`JSON.stringify` alone is not safe to interpolate into HTML. `serializeJsonLd` escapes `<`, `>`, and
`&` so a value containing `` cannot terminate the tag early, and escapes U+2028 / U+2029,
which are legal in JSON but not inside a JavaScript string literal.
```tsx
serializeJsonLd({ text: "" });
// {"text":""}
```
Pass `space` to pretty-print while debugging, and `id` if your framework needs to dedupe or replace
the tag between navigations.
---
URL: https://ui.uptoolkit.com/components/post-card.md
Renders an ActivityPub Note or Article with Schema.org SocialMediaPosting markup.
# Post Card
Renders an ActivityPub Note or Article with Schema.org SocialMediaPosting markup.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/post-card.json
```
[Registry JSON](https://ui.uptoolkit.com/r/post-card.json)
## Preview
```tsx
import {
SAMPLE_NOW,
sampleAnnounce,
samplePost,
sampleViewer,
sensitivePost,
} from "@/lib/social-sample-data";
import { PostCard } from "@/components/ui/post-card";
export function Preview() {
return (
);
}
```
## Source
### ui/post-card.tsx
```tsx
"use client";
import {
IconAlertTriangle,
IconDots,
IconGlobe,
IconLock,
IconMail,
IconRepeat,
IconUsers,
} from "@tabler/icons-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import {
formatPublishedTime,
getActorDisplayName,
getActorHandle,
getObjectParagraphs,
getTags,
getVisibility,
resolveActor,
type ActivityPubActivity,
type ActivityPubActor,
type ActivityPubDocument,
type ActivityPubObject,
type ActivityPubVisibility,
} from "@/lib/activitypub";
import { toSocialMediaPostingJsonLd } from "@/lib/schema-org";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { JsonLd } from "@/components/json-ld";
import { ReactionBar, type ReactionBarProps } from "@/components/ui/reaction-bar";
const visibilityIcons: Record = {
public: IconGlobe,
unlisted: IconLock,
followers: IconUsers,
direct: IconMail,
};
const visibilityLabels: Record = {
public: "Public",
unlisted: "Unlisted",
followers: "Followers only",
direct: "Mentioned people only",
};
/** Facebook-style attachment grids, keyed by attachment count. */
const attachmentGrids: Record = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-2 [&>*:first-child]:row-span-2",
4: "grid-cols-2",
};
type PostCardProps = Omit, "children"> & {
/** The `Note`, `Article`, or other object to render. */
object: ActivityPubObject;
/**
* The wrapping activity. An `Announce` renders the "boosted" attribution
* header above the post.
*/
activity?: ActivityPubActivity;
/** The signed-in actor, forwarded to the reaction bar. */
viewer?: ActivityPubActor;
counts?: ReactionBarProps["counts"];
viewerState?: ReactionBarProps["state"];
defaultViewerState?: ReactionBarProps["defaultState"];
onLike?: ReactionBarProps["onLike"];
onUnlike?: ReactionBarProps["onUnlike"];
onShare?: ReactionBarProps["onShare"];
onUnshare?: ReactionBarProps["onUnshare"];
onReply?: ReactionBarProps["onReply"];
onMore?: (object: ActivityPubObject) => void;
/**
* Custom body renderer. The default renders sanitized plain text with
* linkified hashtags and mentions; pass your own to render trusted HTML.
*/
renderContent?: (object: ActivityPubObject) => React.ReactNode;
/** Renders below the footer. Use for a comment thread. */
children?: React.ReactNode;
/** Emits a Schema.org `SocialMediaPosting` JSON-LD script. */
includeJsonLd?: boolean;
/**
* Fixed "now" for relative timestamps. Pass a stable value on the server to
* avoid a hydration mismatch.
*/
now?: Date | number;
/** BCP 47 locale for dates and counts. Must match on server and client. */
locale?: string;
hideActions?: boolean;
};
function PostAttachments({ attachments }: { attachments: readonly ActivityPubDocument[] }) {
const visible = attachments.slice(0, 4);
const overflow = attachments.length - visible.length;
return (
);
}
type ContentToken = { key: string; text: string; href?: string };
type ContentParagraph = { key: string; tokens: ContentToken[] };
/**
* Splits the plain-text body into paragraphs and tokens, linking hashtags and
* mentions to the hrefs carried in the object's `tag` array.
*
* Keys are byte offsets into the body rather than array indices, so repeated
* words do not collide and reordered content does not remount the wrong node.
*/
function getContentParagraphs(object: ActivityPubObject): ContentParagraph[] {
const mentions = new Map(getTags(object, "Mention").map((tag) => [tag.name, tag.href]));
const hashtags = new Map(getTags(object, "Hashtag").map((tag) => [tag.name, tag.href]));
const paragraphs: ContentParagraph[] = [];
let offset = 0;
for (const paragraph of getObjectParagraphs(object)) {
const tokens: ContentToken[] = [];
let cursor = offset;
for (const text of paragraph.split(/(\s+)/u)) {
const href = mentions.get(text) ?? hashtags.get(text);
tokens.push({ key: `t${cursor}`, text, ...(href ? { href } : {}) });
cursor += text.length;
}
paragraphs.push({ key: `p${offset}`, tokens });
offset = cursor + 2;
}
return paragraphs;
}
/** Plain-text body with hashtags and mentions turned into links. */
function PostContent({ object }: { object: ActivityPubObject }) {
return (
) : null}
{hideActions ? null : (
<>
{children}
>
)}
);
}
export { PostCard, PostContent, type PostCardProps };
```
## Usage
Renders any ActivityPub object as a social post: author identity, visibility, content warning,
attachment grid, hashtags, and the reaction bar.
```tsx
import { PostCard } from "@/components/ui/post-card";
```
### Boosts
Pass the wrapping activity. An `Announce` renders the "boosted" attribution above the post while the
body stays attributed to the original author.
```tsx
```
### Structured data
Schema.org microdata is always in the markup — `SocialMediaPosting` on the root, with `author`,
`datePublished`, `articleBody`, `image`, and `keywords` on the nodes inside. Add `includeJsonLd` to
also emit a JSON-LD script, which is the form Google prefers:
```tsx
```
### Content rendering
Federated `content` is HTML from a server you do not control, so the default renderer converts it to
plain text and linkifies hashtags and mentions from the object's `tag` array. To render HTML, pass
your own renderer and sanitize it yourself:
```tsx
(
)}
/>
```
### Content warnings
When `sensitive` is set and `summary` is present, the body and attachments stay collapsed behind the
warning until the reader expands it.
### Composition
`children` render inside the footer under the reaction bar — the place for a comment thread. Pass
`hideActions` for a read-only card, and a fixed `now` when rendering on the server so relative
timestamps do not shift at hydration. Dates and counts format in `en` by default; pass `locale` —
the same value on server and client — for anything else.
---
URL: https://ui.uptoolkit.com/components/post-composer.md
Composer that emits an ActivityPub Create activity with audience targeting.
# Post Composer
Composer that emits an ActivityPub Create activity with audience targeting.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/post-composer.json
```
[Registry JSON](https://ui.uptoolkit.com/r/post-composer.json)
## Preview
```tsx
import * as React from "react";
import { getVisibility } from "@/lib/activitypub";
import { sampleViewer } from "@/lib/social-sample-data";
import { PostComposer } from "@/components/ui/post-composer";
export function Preview() {
const [activity, setActivity] = React.useState(null);
return (
);
}
export {
ReactionBar,
defaultReactionKinds,
type ReactionBarProps,
type ReactionCounts,
type ReactionKind,
type ReactionViewerState,
};
```
## Usage
Like, boost, reply, and share controls for an ActivityPub object. Every interaction produces an
activity you can deliver: `Like` or `EmojiReact` for reactions, `Announce` for boosts, and `Undo`
wrapping the original when the viewer takes it back.
```tsx
import { ReactionBar } from "@/components/ui/reaction-bar";
postToOutbox(viewer, activity)}
onUnlike={(activity) => postToOutbox(viewer, activity)}
onShare={(activity) => postToOutbox(viewer, activity)}
onReply={(object) => openComposer(object)}
/>
```
### Emoji reactions
Hover or right-click the like button to open the picker. Choosing an emoji emits `EmojiReact` with
the emoji in `content`, which is how the fediverse carries reactions beyond a plain like. Pass your
own set, or `reactions={false}` for a single like button:
```tsx
```
### Counts
Counts come from the object's own `likes`, `shares`, and `replies` collections, so a document
fetched straight from an outbox needs no extra props. Override with `counts` when you track them
separately. The bar assumes the incoming counts exclude the viewer's own action and adds a delta on
top, so numbers stay correct when a viewer likes and then unlikes.
Pass `variant="compact"` for a denser bar and `showBookmark` to add a local bookmark toggle.
---
URL: https://ui.uptoolkit.com/blocks.md
Composed templates and larger UI sections.
# Blocks
Composed templates and larger UI sections.
- [Actor Grid](https://ui.uptoolkit.com/blocks/actor-grid): Filterable people grid with Schema.org ItemList markup.
- [Notifications Panel](https://ui.uptoolkit.com/blocks/notifications-panel): Grouped inbox panel for ActivityPub Like, Announce, Follow, and reply activities.
- [Profile Header](https://ui.uptoolkit.com/blocks/profile-header): Cover, identity, stats, and tabs header with Schema.org ProfilePage markup.
- [Social Feed](https://ui.uptoolkit.com/blocks/social-feed): Timeline block that renders an ActivityPub OrderedCollection with a composer.
- [Stats Panel](https://ui.uptoolkit.com/blocks/stats-panel): A simple multi-file block with a component and local data helper.
- [Wall Feed](https://ui.uptoolkit.com/blocks/wall-feed): Activity feed for a profile wall: posts, boosts, reactions, follows, and photo albums in one grouped stream.
---
URL: https://ui.uptoolkit.com/blocks/actor-grid.md
Filterable people grid with Schema.org ItemList markup.
# Actor Grid
Filterable people grid with Schema.org ItemList markup.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/actor-grid.json
```
[Registry JSON](https://ui.uptoolkit.com/r/actor-grid.json)
## Preview
```tsx
import { sampleSuggestions } from "@/lib/social-sample-data";
import { ActorGrid } from "@/components/actor-grid";
export function Preview() {
return (
);
}
```
## Source
### components/actor-grid.tsx
```tsx
"use client";
import { IconSearch, IconUsersGroup } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { ActorCard } from "@/components/ui/actor-card";
import type { FollowState } from "@/components/ui/follow-button";
import { JsonLd } from "@/components/json-ld";
import {
formatCompactNumber,
getActorDisplayName,
getActorHandle,
getCollectionCount,
getCollectionItems,
toPlainText,
type ActivityPubActor,
type ActivityPubCollection,
} from "@/lib/activitypub";
import { toActorListJsonLd } from "@/lib/schema-org";
import { sampleFollowers } from "@/lib/social-sample-data";
const columnClasses: Record = {
1: "grid-cols-1",
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
};
type ActorGridProps = Omit, "children"> & {
/** A `followers`, `following`, or suggestions collection. */
actors?: ActivityPubCollection | readonly ActivityPubActor[];
heading?: string;
description?: string;
/** Follow state per actor id, so the grid can show mixed relationships. */
followStates?: Readonly>;
onFollowStateChange?: (actor: ActivityPubActor, state: FollowState) => void;
onMessage?: (actor: ActivityPubActor) => void;
/** Card layout: `card` stacks, `row` renders compact list rows. */
variant?: "card" | "row";
columns?: 1 | 2 | 3 | 4;
/** Show the client-side name and handle filter. */
searchable?: boolean;
/** Actors shown before the "show all" control. */
initialCount?: number;
emptyState?: React.ReactNode;
/** Emits a Schema.org `ItemList` of `Person` nodes. */
includeJsonLd?: boolean;
/** BCP 47 locale for dates and counts. Must match on server and client. */
locale?: string;
};
/**
* People grid for an ActivityPub actor collection.
*
* Works for followers, following, and suggestion lists. The total comes from the
* collection's `totalItems` when present, so a paginated collection still
* reports its real size, and the list is described as a Schema.org `ItemList`.
*/
function ActorGrid({
actors = sampleFollowers,
heading = "Followers",
description,
followStates,
onFollowStateChange,
onMessage,
variant = "card",
columns = 3,
searchable = false,
initialCount,
emptyState,
includeJsonLd = true,
locale,
className,
...props
}: ActorGridProps) {
const [query, setQuery] = React.useState("");
const [expanded, setExpanded] = React.useState(false);
const searchId = React.useId();
const items = React.useMemo(() => getCollectionItems(actors), [actors]);
const total = getCollectionCount(actors);
const filtered = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) {
return items;
}
return items.filter((actor) =>
[getActorDisplayName(actor), getActorHandle(actor), toPlainText(actor.summary)]
.join(" ")
.toLowerCase()
.includes(needle),
);
}, [items, query]);
const limit = initialCount ?? filtered.length;
const visible = expanded ? filtered : filtered.slice(0, limit);
const hidden = filtered.length - visible.length;
return (
{includeJsonLd ? : null}
)}
{hidden > 0 ? (
) : null}
);
}
export { ActorGrid, type ActorGridProps };
```
## Usage
One block for every people list: followers, following, mutuals, suggestions, and search results.
```tsx
import { ActorGrid } from "@/components/actor-grid";
const followers = await fetchFollowers(actor);
trackRelationship(actor, state)}
/>
```
Accepts a `Collection` or a plain array. When a collection carries `totalItems`, that is the count
shown in the heading, so a paginated followers list reports its real size instead of the page length.
### Layout
`variant="card"` uses the stacked card with profile fields; `variant="row"` renders compact rows and
ignores `columns`, which is the shape for a sidebar.
```tsx
```
### Filtering
`searchable` adds a client-side filter over display name, handle, and bio. `initialCount` limits the
first render and adds a "show all" control:
```tsx
```
The empty state distinguishes "no results for this query" from "nothing here yet"; override it with
`emptyState`.
### Relationships
Pass `followStates` keyed by actor id to show mixed relationships in one list:
```tsx
```
### Structured data
Emits one Schema.org `ItemList` of `Person` nodes for the whole list, and the cards inside skip
their own JSON-LD so the graph stays clean. Set `includeJsonLd={false}` when the page emits the list
itself.
---
URL: https://ui.uptoolkit.com/blocks/notifications-panel.md
Grouped inbox panel for ActivityPub Like, Announce, Follow, and reply activities.
# Notifications Panel
Grouped inbox panel for ActivityPub Like, Announce, Follow, and reply activities.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/notifications-panel.json
```
[Registry JSON](https://ui.uptoolkit.com/r/notifications-panel.json)
## Preview
```tsx
import { sampleViewer } from "@/lib/social-sample-data";
import { NotificationsPanel } from "@/components/notifications-panel";
export function Preview() {
return ;
}
```
## Source
### components/notifications-panel.tsx
```tsx
"use client";
import {
IconAt,
IconBell,
IconHeartFilled,
IconMessageCircle,
IconRepeat,
IconUserPlus,
} from "@tabler/icons-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import {
formatPublishedTime,
getActorDisplayName,
getCollectionItems,
getObjectId,
resolveActor,
resolveObject,
toPlainText,
type ActivityPubActivity,
type ActivityPubActor,
type ActivityPubCollection,
} from "@/lib/activitypub";
import { SAMPLE_NOW, sampleInbox } from "@/lib/social-sample-data";
type InboxActivity = ActivityPubActivity;
/** Notification categories, mapped from ActivityStreams activity types. */
type NotificationKind = "reaction" | "boost" | "follow" | "mention" | "reply";
const kindConfig: Record<
NotificationKind,
{ label: string; icon: typeof IconBell; tone: string; verb: string }
> = {
reaction: {
label: "Reactions",
icon: IconHeartFilled,
tone: "text-rose-600 dark:text-rose-400",
verb: "reacted to your post",
},
boost: {
label: "Boosts",
icon: IconRepeat,
tone: "text-emerald-600 dark:text-emerald-400",
verb: "boosted your post",
},
follow: {
label: "Follows",
icon: IconUserPlus,
tone: "text-sky-600 dark:text-sky-400",
verb: "followed you",
},
mention: {
label: "Mentions",
icon: IconAt,
tone: "text-violet-600 dark:text-violet-400",
verb: "mentioned you",
},
reply: {
label: "Replies",
icon: IconMessageCircle,
tone: "text-muted-foreground",
verb: "replied to your post",
},
};
/**
* Classifies an inbox activity.
*
* `Create` splits into `reply` or `mention` depending on whether the object
* carries `inReplyTo`, which is how clients separate the two in practice.
*/
function isNotificationKind(value: unknown): value is NotificationKind {
return typeof value === "string" && Object.hasOwn(kindConfig, value);
}
function getNotificationKind(activity: InboxActivity): NotificationKind | undefined {
switch (activity.type) {
case "Like":
case "EmojiReact":
return "reaction";
case "Announce":
return "boost";
case "Follow":
return "follow";
case "Create":
return resolveObject(activity.object)?.inReplyTo ? "reply" : "mention";
default:
return undefined;
}
}
/** Short excerpt of the object the activity refers to. */
function getNotificationExcerpt(activity: InboxActivity): string | undefined {
const object = resolveObject(activity.object);
const text = toPlainText(object?.content) || object?.name;
if (!text) {
return undefined;
}
return text.length > 120 ? `${text.slice(0, 119)}…` : text;
}
type NotificationsPanelProps = Omit, "children"> & {
/** An `inbox` page of activities addressed to the viewer. */
collection?: ActivityPubCollection | readonly InboxActivity[];
/** The signed-in actor, used to label follow-back actions. */
viewer?: ActivityPubActor;
heading?: string;
/** Activity ids the viewer has not seen yet. */
unreadIds?: readonly string[];
/** Hide the category filter row. */
hideFilters?: boolean;
maxHeight?: number | string;
onSelect?: (activity: InboxActivity) => void;
onMarkAllRead?: () => void;
/** Fixed "now" for relative timestamps. */
now?: Date | number;
/** BCP 47 locale for dates. Must match on server and client. */
locale?: string;
};
/**
* Notification panel for an ActivityPub inbox.
*
* Reads `Like`, `EmojiReact`, `Announce`, `Follow`, and `Create` activities and
* groups them into the categories a social client shows, keeping the emoji from
* an `EmojiReact` so the reaction is visible in the row.
*/
function NotificationsPanel({
collection = sampleInbox,
viewer,
heading = "Notifications",
unreadIds,
hideFilters = false,
maxHeight = 420,
onSelect,
onMarkAllRead,
now = SAMPLE_NOW,
locale,
className,
...props
}: NotificationsPanelProps) {
const [filter, setFilter] = React.useState("all");
const [readIds, setReadIds] = React.useState([]);
const entries = React.useMemo(
() =>
getCollectionItems(collection).flatMap((activity) => {
const kind = getNotificationKind(activity);
const actor = resolveActor(activity.actor);
return kind && actor ? [{ activity, kind, actor }] : [];
}),
[collection],
);
const availableKinds = React.useMemo(
() => [...new Set(entries.map((entry) => entry.kind))],
[entries],
);
const visible = filter === "all" ? entries : entries.filter((entry) => entry.kind === filter);
const unread = new Set(unreadIds ?? entries.slice(0, 3).map((entry) => entry.activity.id));
const unreadCount = entries.filter(
(entry) => unread.has(entry.activity.id) && !readIds.includes(entry.activity.id),
).length;
const handleMarkAllRead = () => {
setReadIds(entries.map((entry) => entry.activity.id));
onMarkAllRead?.();
};
return (
)}
);
}
export {
NotificationsPanel,
getNotificationKind,
type InboxActivity,
type NotificationKind,
type NotificationsPanelProps,
};
```
## Usage
Turns a raw ActivityPub `inbox` page into the notification list a social client shows.
```tsx
import { NotificationsPanel } from "@/components/notifications-panel";
const inbox = await fetchNotifications(viewer);
navigate(getObjectId(activity.object))}
onMarkAllRead={() => markAllRead()}
/>
```
### Categories
| Activity | Category |
| ----------------------- | ---------- |
| `Like`, `EmojiReact` | Reactions |
| `Announce` | Boosts |
| `Follow` | Follows |
| `Create` with `inReplyTo` | Replies |
| `Create` without `inReplyTo` | Mentions |
`getNotificationKind` is exported if you need the same classification elsewhere. Activity types
outside the table are skipped, so an unexpected `Add` or `Flag` in the inbox will not render a blank
row. The filter strip only lists categories actually present.
An `EmojiReact` shows its emoji in the badge instead of a generic heart, so the reaction is visible
without opening the post.
### Unread state
Pass `unreadIds` to control which rows are unread; without it the newest three are marked unread as a
demo. Selecting a row marks it read locally and calls `onSelect`.
Pass `maxHeight` to bound the scroll area, or `hideFilters` for a compact rail. Without a
`collection` the panel renders [`social-sample-data`](/utilities/social-sample-data).
---
URL: https://ui.uptoolkit.com/blocks/profile-header.md
Cover, identity, stats, and tabs header with Schema.org ProfilePage markup.
# Profile Header
Cover, identity, stats, and tabs header with Schema.org ProfilePage markup.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/profile-header.json
```
[Registry JSON](https://ui.uptoolkit.com/r/profile-header.json)
## Preview
```tsx
import { sampleViewer } from "@/lib/social-sample-data";
import { ProfileHeader } from "@/components/profile-header";
export function Preview() {
return ;
}
```
## Source
### components/profile-header.tsx
```tsx
"use client";
import { IconCalendar, IconLink, IconMail, IconMapPin } from "@tabler/icons-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { FollowButton, type FollowButtonProps } from "@/components/ui/follow-button";
import { JsonLd } from "@/components/json-ld";
import {
formatCompactNumber,
formatPublishedDate,
getActorDisplayName,
getActorHandle,
toPlainText,
type ActivityPubActor,
} from "@/lib/activitypub";
import { toProfilePageJsonLd, type SocialInteractionCounts } from "@/lib/schema-org";
import { sampleAuthor, sampleCounts } from "@/lib/social-sample-data";
/** Field names that get a matching icon instead of a plain label. */
const fieldIcons: Record = {
location: IconMapPin,
website: IconLink,
email: IconMail,
};
type ProfileTab = {
value: string;
label: string;
/** Optional count rendered as a badge next to the label. */
count?: number;
content?: React.ReactNode;
};
type ProfileHeaderProps = Omit, "children"> & {
actor?: ActivityPubActor;
/** The signed-in actor. Omit for a signed-out view. */
viewer?: ActivityPubActor;
counts?: SocialInteractionCounts;
followState?: FollowButtonProps["state"];
defaultFollowState?: FollowButtonProps["defaultState"];
onFollow?: FollowButtonProps["onFollow"];
onUnfollow?: FollowButtonProps["onUnfollow"];
onMessage?: (actor: ActivityPubActor) => void;
/** Tabs rendered under the header. Pass `[]` to render the header alone. */
tabs?: readonly ProfileTab[];
defaultTab?: string;
/** Emits a Schema.org `ProfilePage` JSON-LD script. */
includeJsonLd?: boolean;
/** BCP 47 locale for dates and counts. Must match on server and client. */
locale?: string;
};
const defaultTabs: readonly ProfileTab[] = [
{ value: "posts", label: "Posts" },
{ value: "replies", label: "Replies" },
{ value: "media", label: "Media" },
{ value: "about", label: "About" },
];
function ProfileStat({ label, value, locale }: { label: string; value: number; locale?: string }) {
return (
{label}
{formatCompactNumber(value, locale)}
);
}
/**
* Profile header for an ActivityPub actor.
*
* Cover art comes from the actor's `image`, the avatar from `icon`, and the
* metadata rows from `attachment` PropertyValue fields, including Mastodon's
* `verifiedAt` link verification. The whole block is described by a Schema.org
* `ProfilePage` whose `mainEntity` is the actor.
*/
function ProfileHeader({
actor = sampleAuthor,
viewer,
counts = sampleCounts.author,
followState,
defaultFollowState,
onFollow,
onUnfollow,
onMessage,
tabs = defaultTabs,
defaultTab,
includeJsonLd = true,
locale,
className,
...props
}: ProfileHeaderProps) {
const displayName = getActorDisplayName(actor);
const bio = toPlainText(actor.summary);
const joined = formatPublishedDate(actor.published, locale === undefined ? {} : { locale });
const fields = actor.attachment ?? [];
const verified = fields.some((field) => Boolean(field.verifiedAt));
const isSelf = viewer?.id === actor.id;
const aboutPanel = (
{tabs.length > 0 ? (
<>
{tabs.map((tab) => (
{tab.label}
{tab.count === undefined ? null : (
{formatCompactNumber(tab.count, locale)}
)}
))}
{tabs.map((tab) => (
{tab.content ?? (tab.value === "about" ? aboutPanel : null)}
))}
>
) : null}
);
}
export { ProfileHeader, defaultTabs, type ProfileHeaderProps, type ProfileTab };
```
## Usage
The top of a profile page: cover art, avatar, identity, bio, counts, follow and message actions, and
a tab strip.
```tsx
import { ProfileHeader } from "@/components/profile-header";
postToOutbox(viewer, activity)}
/>
```
Everything is read from the actor document: cover from `image`, avatar from `icon`, bio from
`summary`, join date from `published`, and the About panel from `attachment` PropertyValue fields
including `verifiedAt` link verification. Actors with no `image` get a gradient rather than a gap.
### Tabs
Tabs are data. Pass counts for badges and `content` to render a panel; the `about` tab falls back to
the actor's profile fields when you leave its content empty.
```tsx
},
{ value: "followers", label: "Followers", count: 18940, content: },
{ value: "about", label: "About" },
]}
/>
```
Pass `tabs={[]}` to render the header alone, which is what you want when the page owns its own
navigation.
### Viewer state
When `viewer.id` matches the actor, the follow and message actions are replaced with "Edit profile".
Actors with `manuallyApprovesFollowers` show an "Approves" badge, so a follow request is not a
surprise.
### Structured data
Emits a Schema.org `ProfilePage` whose `mainEntity` is the actor, plus matching microdata inline. Set
`includeJsonLd={false}` when the page already emits its own `ProfilePage` node — as
[`social-profile-page`](/pages/social-profile-page) does — to avoid duplicates.
---
URL: https://ui.uptoolkit.com/blocks/social-feed.md
Timeline block that renders an ActivityPub OrderedCollection with a composer.
# Social Feed
Timeline block that renders an ActivityPub OrderedCollection with a composer.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/social-feed.json
```
[Registry JSON](https://ui.uptoolkit.com/r/social-feed.json)
## Preview
```tsx
import { SocialFeed } from "@/components/social-feed";
export function Preview() {
return ;
}
```
## Source
### components/social-feed.tsx
```tsx
"use client";
import { IconRefresh } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CommentThread } from "@/components/ui/comment-thread";
import { JsonLd } from "@/components/json-ld";
import { PostCard } from "@/components/ui/post-card";
import { PostComposer } from "@/components/ui/post-composer";
import {
getActivityObject,
getCollectionItems,
type ActivityPubActivity,
type ActivityPubActor,
type ActivityPubCollection,
type ActivityPubObject,
} from "@/lib/activitypub";
import { toFeedJsonLd } from "@/lib/schema-org";
import {
SAMPLE_NOW,
sampleFeed,
sampleViewer,
} from "@/lib/social-sample-data";
type FeedActivity = ActivityPubActivity;
type SocialFeedProps = Omit, "children"> & {
/** The signed-in actor. Drives the composer and interaction handlers. */
viewer?: ActivityPubActor;
/** An `OrderedCollection` page from an inbox or outbox. */
collection?: ActivityPubCollection | readonly FeedActivity[];
heading?: string;
/** Hide the composer, for example on a read-only public timeline. */
hideComposer?: boolean;
/** Show an inline comment thread under each post. */
showComments?: boolean;
/** Called with the `Create` activity from the composer. */
onPublish?: (activity: FeedActivity) => void | Promise;
/** Called with the collection's `next` page URL. */
onLoadMore?: (next: string) => void | Promise;
/** Any activity emitted by a post: Like, EmojiReact, Announce, or Undo. */
onActivity?: (activity: ActivityPubActivity) => void;
/** Fixed "now" for relative timestamps. */
now?: Date | number;
/** BCP 47 locale for dates and counts. Must match on server and client. */
locale?: string;
};
/**
* Timeline block for an ActivityPub collection.
*
* Accepts an `OrderedCollection` of `Create` and `Announce` activities and
* renders each wrapped object, so boosts keep their attribution. Newly composed
* posts are prepended optimistically, and the whole timeline is described by a
* Schema.org `CollectionPage` JSON-LD node.
*/
function SocialFeed({
viewer = sampleViewer,
collection = sampleFeed,
heading = "Home",
hideComposer = false,
showComments = false,
onPublish,
onLoadMore,
onActivity,
now = SAMPLE_NOW,
locale,
className,
...props
}: SocialFeedProps) {
/** Posts composed in this session, kept until the caller refetches. */
const [published, setPublished] = React.useState([]);
const [loadingMore, setLoadingMore] = React.useState(false);
const activities = React.useMemo(
() => [...published, ...getCollectionItems(collection)],
[collection, published],
);
const next = collection && "type" in collection ? collection.next : undefined;
const objects = React.useMemo(
() =>
activities.flatMap((activity) => {
const object = getActivityObject(activity);
return object ? [{ activity, object }] : [];
}),
[activities],
);
const handlePublish = React.useCallback(
async (activity: FeedActivity) => {
setPublished((current) => [activity, ...current]);
await onPublish?.(activity);
},
[onPublish],
);
const handleLoadMore = React.useCallback(async () => {
if (!next || loadingMore) {
return;
}
setLoadingMore(true);
try {
await onLoadMore?.(next);
} finally {
setLoadingMore(false);
}
}, [loadingMore, next, onLoadMore]);
return (
entry.object),
{
name: heading,
...(collection && "type" in collection && collection.id ? { url: collection.id } : {}),
},
)}
/>
{hideComposer ? null : (
handlePublish(activity)} />
)}
{objects.map(({ activity, object }) => (
{showComments ? (
<>
>
) : null}
))}
{next ? (
) : null}
);
}
export { SocialFeed, type FeedActivity, type SocialFeedProps };
```
## Usage
The timeline. Hand it an `OrderedCollection` page from an `inbox` or `outbox` and it renders each
activity's object, keeping boost attribution intact.
```tsx
import { SocialFeed } from "@/components/social-feed";
const collection = await fetchInbox(viewer);
postToOutbox(viewer, activity)}
onLoadMore={(next) => fetchPage(next)}
/>
```
`Create` and `Announce` activities both render; anything without a resolvable object is skipped
rather than rendered as an empty card.
### Publishing
Posts composed in the feed are prepended immediately and kept until you refetch, so the timeline does
not appear to swallow a new post while the request is in flight. `onPublish` receives the same
`Create` activity the composer built.
### Pagination
When the collection has a `next` URL, a "Load older posts" button appears and passes that URL to
`onLoadMore`. Merge the page into your collection state and pass it back down.
### Interactions
`onActivity` receives every activity any post emits — `Like`, `EmojiReact`, `Announce`, and the
`Undo` for each — so a single handler can deliver them all:
```tsx
postToOutbox(viewer, activity)} />
```
### Structured data
The block emits one Schema.org `CollectionPage` wrapping an `ItemList` of postings, so the timeline
is described once rather than per card.
Without a `collection` the block renders
[`social-sample-data`](/utilities/social-sample-data) so it works the moment it is installed. Pass
`hideComposer` for a public timeline and a fixed `now` when rendering on the server.
---
URL: https://ui.uptoolkit.com/blocks/stats-panel.md
A simple multi-file block with a component and local data helper.
# Stats Panel
A simple multi-file block with a component and local data helper.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/stats-panel.json
```
[Registry JSON](https://ui.uptoolkit.com/r/stats-panel.json)
## Preview
```tsx
import { StatsPanel } from "@/components/stats-panel";
export function Preview() {
return ;
}
```
## Source
### components/stats-panel.tsx
```tsx
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ExampleCard } from "@/components/ui/example-card";
import { stats } from "@/lib/stats-data";
function StatsPanel() {
return (
Registry health
{stats.map((stat) => (
{stat.label}{stat.detail}
{stat.value}
))}
);
}
export { StatsPanel };
```
### lib/stats-data.ts
```ts
type Stat = {
label: string;
value: string;
detail: string;
};
const stats: Stat[] = [
{
label: "Components",
value: "12",
detail: "Ready to publish",
},
{
label: "Blocks",
value: "3",
detail: "Documented examples",
},
{
label: "Schema",
value: "100%",
detail: "Validated output",
},
];
export { stats, type Stat };
```
## Usage
Use the panel as a dashboard summary block. It installs the component and the local data helper
together, plus its local `example-card` dependency.
```tsx
import { StatsPanel } from "@/components/stats-panel";
```
---
URL: https://ui.uptoolkit.com/blocks/wall-feed.md
Activity feed for a profile wall: posts, boosts, reactions, follows, and photo albums in one grouped stream.
# Wall Feed
Activity feed for a profile wall: posts, boosts, reactions, follows, and photo albums in one grouped stream.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/wall-feed.json
```
[Registry JSON](https://ui.uptoolkit.com/r/wall-feed.json)
## Preview
```tsx
import { WallFeed } from "@/components/wall-feed";
export function Preview() {
return ;
}
```
## Source
### components/wall-feed.tsx
```tsx
"use client";
import {
IconCalendarPlus,
IconHeartFilled,
IconPhoto,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { CommentThread } from "@/components/ui/comment-thread";
import { JsonLd } from "@/components/json-ld";
import { PostCard } from "@/components/ui/post-card";
import { PostComposer } from "@/components/ui/post-composer";
import {
formatPublishedTime,
getActorDisplayName,
getCollectionItems,
toPlainText,
type ActivityPubActivity,
type ActivityPubActor,
type ActivityPubCollection,
type ActivityPubDocument,
type ActivityPubObject,
} from "@/lib/activitypub";
import { toFeedJsonLd } from "@/lib/schema-org";
import {
SAMPLE_NOW,
sampleAuthor,
sampleViewer,
} from "@/lib/social-sample-data";
import {
countWallEntries,
filterWallEntries,
groupWallEntries,
sampleWallCollection,
toWallEntries,
type WallActivity,
type WallEntry,
type WallEntryKind,
type WallFilter,
} from "@/lib/wall-activities";
/** Copy and iconography for the collapsed, non-post rows. */
/** The kinds that collapse to a single line. `post` and `share` render as cards. */
type WallRowKind = Exclude;
const kindConfig: Record<
WallRowKind,
{ icon: typeof IconHeartFilled; tone: string; verb: string }
> = {
reaction: {
icon: IconHeartFilled,
tone: "text-rose-600 dark:text-rose-400",
verb: "reacted to a post",
},
follow: {
icon: IconUserPlus,
tone: "text-sky-600 dark:text-sky-400",
verb: "started following",
},
album: {
icon: IconPhoto,
tone: "text-amber-600 dark:text-amber-400",
verb: "added photos to an album",
},
profile: {
icon: IconCalendarPlus,
tone: "text-violet-600 dark:text-violet-400",
verb: "updated their profile",
},
join: {
icon: IconUsersGroup,
tone: "text-teal-600 dark:text-teal-400",
verb: "joined a group",
},
};
const filterLabels = {
all: "All activity",
posts: "Posts",
photos: "Photos",
people: "People",
wall: "Wall posts",
} as const satisfies Record;
const filterOrder = [
"all",
"posts",
"photos",
"people",
"wall",
] as const satisfies readonly WallFilter[];
function isWallFilter(value: unknown): value is WallFilter {
return typeof value === "string" && filterOrder.some((filter) => filter === value);
}
type WallFeedProps = Omit, "children"> & {
/** The actor whose wall this is. Drives addressing and the visitor split. */
owner?: ActivityPubActor;
/** The signed-in actor. Omit for a signed-out, read-only wall. */
viewer?: ActivityPubActor;
/** An `outbox` page of wall activities. */
collection?: ActivityPubCollection | readonly WallActivity[];
heading?: string;
defaultFilter?: WallFilter;
/** Hide the filter row, for an embedded or single-purpose wall. */
hideFilters?: boolean;
/** Hide the composer, for a wall that does not accept visitor posts. */
hideComposer?: boolean;
/** Show an inline comment thread under each post. */
showComments?: boolean;
/** Called with the `Create` activity from the composer. */
onPublish?: (activity: ActivityPubActivity) => void | Promise;
/** Called with the collection's `next` page URL. */
onLoadMore?: (next: string) => void | Promise;
/** Any activity a post emits: Like, EmojiReact, Announce, or the Undo. */
onActivity?: (activity: ActivityPubActivity) => void;
/** Called when a collapsed activity row is selected. */
onSelect?: (entry: WallEntry) => void;
/** Fixed "now" for relative timestamps. */
now?: Date | number;
/** BCP 47 locale for dates and counts. Must match on server and client. */
locale?: string;
};
/**
* Activity feed for a profile wall.
*
* Unlike a timeline, this renders the whole activity vocabulary: `Create` and
* `Announce` become full post cards, while reactions, follows, album additions,
* profile changes, and group joins collapse into single attributed lines. The
* stream is bucketed by day and described by one Schema.org `CollectionPage`.
*/
function WallFeed({
owner = sampleAuthor,
viewer = sampleViewer,
collection = sampleWallCollection,
heading,
defaultFilter = "all",
hideFilters = false,
hideComposer = false,
showComments = false,
onPublish,
onLoadMore,
onActivity,
onSelect,
now = SAMPLE_NOW,
locale,
className,
...props
}: WallFeedProps) {
const [filter, setFilter] = React.useState(defaultFilter);
/** Posts written in this session, kept until the caller refetches. */
const [published, setPublished] = React.useState([]);
const [loadingMore, setLoadingMore] = React.useState(false);
const ownerName = getActorDisplayName(owner);
const label = heading ?? `${ownerName}'s wall`;
const next = collection && "type" in collection ? collection.next : undefined;
const entries = React.useMemo(
() => toWallEntries([...published, ...getCollectionItems(collection)], owner),
[collection, owner, published],
);
const visible = React.useMemo(() => filterWallEntries(entries, filter), [entries, filter]);
const groups = React.useMemo(
() => groupWallEntries(visible, { now, ...(locale === undefined ? {} : { locale }) }),
[visible, now, locale],
);
// Only the full post cards belong in the CollectionPage; a "liked a post"
// row is not itself a posting.
const postings = React.useMemo(
() =>
visible.flatMap((entry) =>
(entry.kind === "post" || entry.kind === "share") && entry.object ? [entry.object] : [],
),
[visible],
);
const counts = React.useMemo(() => countWallEntries(entries), [entries]);
const handlePublish = React.useCallback(
async (activity: ActivityPubActivity) => {
setPublished((current) => [activity, ...current]);
await onPublish?.(activity);
},
[onPublish],
);
const handleLoadMore = React.useCallback(async () => {
if (!next || loadingMore) {
return;
}
setLoadingMore(true);
try {
await onLoadMore?.(next);
} finally {
setLoadingMore(false);
}
}, [loadingMore, next, onLoadMore]);
return (
{hideComposer || !viewer ? null : (
handlePublish(activity)}
/>
)}
{hideFilters ? null : (
setFilter(isWallFilter(value) ? value : "all")}
>
{filterOrder.map((value) => (
{filterLabels[value]}
{counts[value]}
))}
)}
{groups.length === 0 ? (
Nothing here yet
{filter === "all"
? `${ownerName} has not posted anything.`
: "Try a different filter."}
) : null}
);
}
/**
* The trailing noun for a collapsed row: an album title, the followed actor's
* name, or a short excerpt of the post that was reacted to.
*/
function getEntryDetail(entry: WallEntry): string | undefined {
if (entry.kind === "follow" || entry.kind === "join") {
return entry.target ? getActorDisplayName(entry.target) : undefined;
}
if (entry.kind === "album" || entry.kind === "profile") {
return entry.object?.name ?? undefined;
}
const text = toPlainText(entry.object?.content) || entry.object?.name;
if (!text) {
return undefined;
}
return `“${text.length > 60 ? `${text.slice(0, 59)}…` : text}”`;
}
export { WallFeed, kindConfig, type WallFeedProps };
```
### lib/wall-activities.ts
```ts
/**
* Wall activity vocabulary and fixtures.
*
* A wall stream is wider than a timeline: alongside `Create` and `Announce` it
* carries the small social signals — reactions, follows, joins, album additions,
* profile changes — that a plain feed drops on the floor. Everything here is
* shaped like real ActivityStreams so swapping in an `outbox` page is a matter
* of replacing {@link sampleWallCollection}.
*/
import {
ACTIVITY_STREAMS_CONTEXT,
PUBLIC_AUDIENCE,
resolveActor,
type ActivityPubActivity,
type ActivityPubActor,
type ActivityPubCollection,
type ActivityPubDocument,
type ActivityPubObject,
} from "@/lib/activitypub";
import {
SAMPLE_NOW,
articlePost,
buildCreateActivity,
mentionPost,
samplePost,
sampleAuthor,
sampleFollowers,
sampleSuggestions,
sampleViewer,
} from "@/lib/social-sample-data";
/**
* Any activity a wall can carry.
*
* The object may be a bare IRI, an inlined object, or — for `Follow` and
* `Join` — the actor or group being acted upon.
*/
export type WallActivity = ActivityPubActivity;
/**
* How a wall entry is rendered.
*
* `post` entries get a full card with reactions and comments; every other kind
* collapses to a single attributed line.
*/
export type WallEntryKind = "post" | "share" | "reaction" | "follow" | "album" | "profile" | "join";
/** Filter buckets shown above the stream. */
export type WallFilter = "all" | "posts" | "photos" | "people" | "wall";
export type WallEntry = {
activity: WallActivity;
actor: ActivityPubActor;
kind: WallEntryKind;
/** The resolved object, when the activity carries one inline. */
object?: ActivityPubObject;
/** The actor or group acted upon, for `Follow` and `Join`. */
target?: ActivityPubActor;
/** True when this is a post someone else wrote *on* the owner's wall. */
fromVisitor: boolean;
published: string;
};
/** An actor is the only thing in the vocabulary with a `preferredUsername`. */
function isWallActor(value: WallActivity["object"]): value is ActivityPubActor {
return typeof value === "object" && value !== null && "preferredUsername" in value;
}
function isWallObject(value: WallActivity["object"]): value is ActivityPubObject {
return typeof value === "object" && value !== null && "attributedTo" in value;
}
function minutesAgo(minutes: number): string {
return new Date(SAMPLE_NOW.getTime() - minutes * 60_000).toISOString();
}
function buildImage(seed: string, name: string): ActivityPubDocument {
return {
type: "Image",
mediaType: "image/svg+xml",
url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}`,
name,
width: 600,
height: 600,
};
}
/** A note written by a visitor onto the owner's wall. */
function buildWallPost(options: {
author: ActivityPubActor;
owner: ActivityPubActor;
slug: string;
content: string;
minutesAgo: number;
attachment?: readonly ActivityPubDocument[];
likes?: number;
shares?: number;
}): ActivityPubObject {
const id = `${options.author.id}/posts/${options.slug}`;
return {
id,
type: "Note",
attributedTo: options.author,
content: options.content,
published: minutesAgo(options.minutesAgo),
url: id,
inLanguage: "en",
// A wall post is addressed to the owner as well as the public, which is
// what marks it as "on their wall" rather than a post that merely mentions
// them.
to: [PUBLIC_AUDIENCE],
cc: [options.owner.id],
audience: [options.owner.id],
tag: [{ type: "Mention", name: `@${options.owner.preferredUsername}`, href: options.owner.id }],
...(options.attachment ? { attachment: options.attachment } : {}),
likes: { type: "Collection", id: `${id}/likes`, totalItems: options.likes ?? 0 },
shares: { type: "Collection", id: `${id}/shares`, totalItems: options.shares ?? 0 },
replies: { type: "Collection", id: `${id}/replies`, totalItems: 0, items: [] },
};
}
const welcomeWallPost = buildWallPost({
author: sampleViewer,
owner: sampleAuthor,
slug: "wall-welcome",
minutesAgo: 18,
content:
"
Congratulations on the atlas launch. Ten years of survey work and it finally has a front door.
Found these from the Svalbard trip. You were holding the theodolite backwards.
",
attachment: [
buildImage("svalbard-1", "Two surveyors on a snowfield with a theodolite"),
buildImage("svalbard-2", "A weathered field notebook open on a rock"),
],
likes: 88,
shares: 4,
});
const albumPhotos: readonly ActivityPubDocument[] = [
buildImage("atlas-plate-1", "Contour plate one, inked"),
buildImage("atlas-plate-2", "Contour plate two, inked"),
buildImage("atlas-plate-3", "Contour plate three, with survey annotations"),
buildImage("atlas-plate-4", "Contour plate four, coastline detail"),
buildImage("atlas-plate-5", "Contour plate five, elevation shading"),
];
const photoAlbum: ActivityPubObject = {
id: `${sampleAuthor.id}/collections/atlas-plates`,
type: "Page",
attributedTo: sampleAuthor,
name: "Atlas plates, 2019-2025",
content: "
Every inked plate from the atlas, in order.
",
published: minutesAgo(1450),
url: `${sampleAuthor.id}/collections/atlas-plates`,
attachment: albumPhotos,
};
const coverUpdate: ActivityPubObject = {
id: `${sampleAuthor.id}#cover`,
type: "Image",
attributedTo: sampleAuthor,
name: "New cover image",
published: minutesAgo(2600),
attachment: [buildImage("mira-cover-2025", "Cover image: a stylised contour field")],
};
/**
* A wall page shaped like an `outbox` response, newest first.
*
* Deliberately mixes every activity type the feed knows how to render so the
* block is a working demonstration the moment it is installed.
*/
export const sampleWallCollection: ActivityPubCollection = {
"@context": ACTIVITY_STREAMS_CONTEXT,
id: `${sampleAuthor.id}/outbox?page=1`,
type: "OrderedCollection",
totalItems: 486,
orderedItems: [
buildCreateActivity(welcomeWallPost),
{
id: `${sampleFollowers[1].id}/activities/like/5512`,
type: "Like",
actor: sampleFollowers[1],
object: samplePost,
published: minutesAgo(26),
},
buildCreateActivity(samplePost),
{
id: `${sampleAuthor.id}/activities/announce/881`,
type: "Announce",
actor: sampleAuthor,
object: articlePost,
published: minutesAgo(96),
},
{
id: `${sampleSuggestions[0].id}/activities/follow/301`,
type: "Follow",
actor: sampleSuggestions[0],
object: sampleAuthor.id,
published: minutesAgo(140),
},
buildCreateActivity(photoWallPost),
{
id: `${sampleFollowers[3].id}/activities/emojireact/992`,
type: "EmojiReact",
actor: sampleFollowers[3],
content: "\u{1F5FA}\u{FE0F}",
object: samplePost,
published: minutesAgo(400),
},
buildCreateActivity(mentionPost),
{
id: `${sampleAuthor.id}/activities/add/77`,
type: "Add",
actor: sampleAuthor,
object: photoAlbum,
target: `${sampleAuthor.id}/collections/atlas-plates`,
published: minutesAgo(1450),
},
{
id: `${sampleAuthor.id}/activities/join/4`,
type: "Join",
actor: sampleAuthor,
object: sampleFollowers[4],
published: minutesAgo(2100),
},
{
id: `${sampleAuthor.id}/activities/update/12`,
type: "Update",
actor: sampleAuthor,
object: coverUpdate,
published: minutesAgo(2600),
},
],
next: `${sampleAuthor.id}/outbox?page=2`,
};
/** Classifies an activity into the row the wall renders for it. */
export function getWallEntryKind(activity: WallActivity): WallEntryKind | undefined {
switch (activity.type) {
case "Create":
return "post";
case "Announce":
return "share";
case "Like":
case "EmojiReact":
return "reaction";
case "Follow":
return "follow";
case "Add":
return "album";
case "Update":
return "profile";
case "Join":
return "join";
default:
return undefined;
}
}
/**
* A post is "on the wall" when it is addressed to the owner by someone else,
* which is how the visitor/owner split is drawn without a second field.
*/
function isVisitorPost(object: ActivityPubObject | undefined, ownerId: string): boolean {
if (!object) {
return false;
}
return [...(object.cc ?? []), ...(object.audience ?? [])].includes(ownerId);
}
/** Resolves a collection of raw activities into renderable wall entries. */
export function toWallEntries(
activities: readonly WallActivity[],
owner: ActivityPubActor,
): WallEntry[] {
return activities.flatMap((activity) => {
const kind = getWallEntryKind(activity);
const actor = resolveActor(activity.actor);
if (!kind || !actor) {
return [];
}
const object = isWallObject(activity.object) ? activity.object : undefined;
// A `Follow` on someone's own wall usually addresses them by bare IRI, so
// resolve that back to the owner rather than rendering a verb with no noun.
const target = isWallActor(activity.object)
? activity.object
: activity.object === owner.id
? owner
: undefined;
const published = activity.published ?? object?.published;
if (!published) {
return [];
}
return [
{
activity,
actor,
kind,
...(object ? { object } : {}),
...(target ? { target } : {}),
fromVisitor: actor.id !== owner.id && isVisitorPost(object, owner.id),
published,
},
];
});
}
const filterPredicates = {
all: () => true,
posts: (entry: WallEntry) => entry.kind === "post" || entry.kind === "share",
photos: (entry: WallEntry) =>
entry.kind === "album" ||
entry.kind === "profile" ||
(entry.object?.attachment ?? []).some((file) => file.type === "Image"),
people: (entry: WallEntry) => entry.kind === "follow" || entry.kind === "join",
wall: (entry: WallEntry) => entry.kind === "post" && entry.fromVisitor,
} as const satisfies Record boolean>;
export function filterWallEntries(entries: readonly WallEntry[], filter: WallFilter): WallEntry[] {
return entries.filter(filterPredicates[filter]);
}
/** Size of every filter bucket, so the filter row can label and disable tabs. */
export function countWallEntries(entries: readonly WallEntry[]): Record {
return {
all: filterWallEntries(entries, "all").length,
posts: filterWallEntries(entries, "posts").length,
photos: filterWallEntries(entries, "photos").length,
people: filterWallEntries(entries, "people").length,
wall: filterWallEntries(entries, "wall").length,
};
}
export type WallEntryGroup = {
/** Stable key derived from the calendar day, safe for SSR. */
id: string;
label: string;
entries: WallEntry[];
};
/**
* Buckets entries into "Today", "Yesterday", then month headings.
*
* The label is computed from `now` rather than the wall clock so a server render
* and its hydration agree.
*/
export function groupWallEntries(
entries: readonly WallEntry[],
options: { now?: Date | number; locale?: string } = {},
): WallEntryGroup[] {
const now = new Date(options.now ?? SAMPLE_NOW);
const today = toDayKey(now);
const yesterday = toDayKey(new Date(now.getTime() - 86_400_000));
const groups: WallEntryGroup[] = [];
for (const entry of entries) {
const date = new Date(entry.published);
const dayKey = toDayKey(date);
const id = dayKey === today || dayKey === yesterday ? dayKey : toMonthKey(date);
const label =
dayKey === today
? "Today"
: dayKey === yesterday
? "Yesterday"
: formatMonth(date, options.locale);
const current = groups.at(-1);
if (current?.id === id) {
current.entries.push(entry);
} else {
groups.push({ id, label, entries: [entry] });
}
}
return groups;
}
function toDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function toMonthKey(date: Date): string {
return date.toISOString().slice(0, 7);
}
function formatMonth(date: Date, locale?: string): string {
return new Intl.DateTimeFormat(locale ?? "en-US", {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(date);
}
```
## Usage
A wall is wider than a timeline. Alongside `Create` and `Announce` it carries the small signals a
plain feed throws away — reactions, follows, album additions, profile changes, group joins — and
this block renders all of them in one stream.
```tsx
import { WallFeed } from "@/components/wall-feed";
const outbox = await fetchOutbox(owner);
postToOutbox(viewer, activity)}
onLoadMore={(next) => fetchPage(next)}
/>
```
### Two row shapes
`Create` and `Announce` render as full [`post-card`](/components/post-card) cards with reactions and
an optional comment thread. Everything else collapses to a single attributed line with an avatar, a
verb, and a timestamp — the same visual language as
[`notifications-panel`](/blocks/notifications-panel), so the two read as one system. An `Add` of a
photo album additionally renders a thumbnail strip.
### Owner posts and visitor posts
A post counts as written *on* the wall when it addresses the owner in `cc` or `audience` and comes
from someone else. Those get a "wrote on X's wall" ribbon above the card, and the **Wall posts**
filter narrows to exactly that set. No extra field is needed — the addressing already says it.
### Filters and grouping
The filter row counts each bucket up front and disables the empty ones, so you never tab into a
blank list. Entries are then bucketed into `Today`, `Yesterday`, and month headings. Both the
buckets and the relative timestamps are computed from the `now` prop rather than the wall clock, so
a server render and its hydration agree.
### Publishing and pagination
Composed posts are prepended immediately and kept until you refetch, so the wall never appears to
swallow a post in flight. When the collection has a `next` URL, a "Load earlier activity" button
passes it to `onLoadMore`.
### Structured data
The block emits one Schema.org `CollectionPage` wrapping an `ItemList` of postings. Only real posts
go in it — a "liked a post" row is not itself a posting, so it is left out rather than padding the
list.
Without a `collection` the block renders its own `wall-activities` fixtures, which cover every
activity type it knows how to draw.
---
URL: https://ui.uptoolkit.com/layouts.md
Reusable app shells and page layouts you can drop straight into a route.
# Layouts
Reusable app shells and page layouts you can drop straight into a route.
- [App Shell](https://ui.uptoolkit.com/layouts/app-shell): Primary authenticated app shell with a top nav, search, notifications, and a user menu around a content slot.
- [Public Profile Shell](https://ui.uptoolkit.com/layouts/public-profile-shell): Actor profile layout that pairs the profile header with a two-column content slot for signed-out visitors.
- [Public Shell](https://ui.uptoolkit.com/layouts/public-shell): Signed-out marketing shell with a top nav, sign-in actions, and a footer around a content slot.
- [Settings Shell](https://ui.uptoolkit.com/layouts/settings-shell): Sidebar section nav and content pane shell for account settings pages.
---
URL: https://ui.uptoolkit.com/layouts/app-shell.md
Primary authenticated app shell with a top nav, search, notifications, and a user menu around a content slot.
# App Shell
Primary authenticated app shell with a top nav, search, notifications, and a user menu around a content slot.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/app-shell.json
```
[Registry JSON](https://ui.uptoolkit.com/r/app-shell.json)
## Preview
```tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { sampleViewer } from "@/lib/social-sample-data";
import { AppShell } from "@/components/app-shell";
export function Preview() {
return (
{}}
nav={[
{ label: "Home", href: "#", active: true },
{ label: "Explore", href: "#" },
{ label: "Notifications", href: "#" },
]}
>
Feed
Page content renders in the shell's content slot.
);
}
```
## Source
### components/app-shell.tsx
```tsx
"use client";
import {
IconBell,
IconLogout,
IconMenu2,
IconSearch,
IconSettings,
IconUser,
type Icon,
} from "@tabler/icons-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import {
getActorDisplayName,
getActorHandle,
type ActivityPubActor,
} from "@/lib/activitypub";
import { sampleViewer } from "@/lib/social-sample-data";
type AppShellNavItem = {
label: string;
href: string;
icon?: Icon;
/** Marks the current route. The shell does not do routing itself. */
active?: boolean;
};
type AppShellProps = {
/** The signed-in actor, shown in the header avatar and account menu. */
viewer?: ActivityPubActor;
brand?: { name: string; href?: string };
nav?: readonly AppShellNavItem[];
/** Shows a count badge on the notifications button. */
notificationsCount?: number;
onNotificationsClick?: () => void;
/** Renders a search field in the header and reports submitted queries. */
onSearch?: (query: string) => void;
onProfileClick?: () => void;
onSettingsClick?: () => void;
onSignOut?: () => void;
children?: React.ReactNode;
className?: string;
};
const defaultNav: readonly AppShellNavItem[] = [
{ label: "Home", href: "#", active: true },
{ label: "Explore", href: "#" },
{ label: "Notifications", href: "#" },
];
/**
* Primary shell for the signed-in app: sticky header with nav, search,
* notifications, and an account menu, wrapped around a content slot.
*
* The header collapses into a `Sheet` under `md`. Nav items are plain
* anchors with an `active` flag rather than a router `Link`, so swapping in
* your router's link component is the only integration step.
*/
function AppShell({
viewer = sampleViewer,
brand = { name: "Uptoolkit", href: "#" },
nav = defaultNav,
notificationsCount = 0,
onNotificationsClick,
onSearch,
onProfileClick,
onSettingsClick,
onSignOut,
children,
className,
}: AppShellProps) {
const [mobileOpen, setMobileOpen] = React.useState(false);
const displayName = getActorDisplayName(viewer);
return (
);
}
export { PublicProfileShell, type PublicProfileShellProps };
```
## Usage
A minimal top bar, the [`ProfileHeader`](/blocks/profile-header) block, and a two-column area for the
timeline plus an optional rail — the layout `profile-page` and `wall-page` compose inline, pulled out
so a public profile route can reuse it directly.
```tsx
import { PublicProfileShell } from "@/components/public-profile-shell";
}>
;
```
### Signed-out follow
`viewer` isn't a prop here — the shell always renders `ProfileHeader` without one, since the visitor
isn't signed in. `onFollow` and `onUnfollow` still fire after the optimistic UI update, so redirect to
`signInHref` from one of them if you want the follow button to gate on sign-in instead of failing
silently.
### Layout
`aside` renders in a `lg:w-80` rail next to `children`; pass `asideFirst` to put it before the main
column, matching a wall-style layout instead of a feed-style one. Omit `aside` for a single-column
page.
---
URL: https://ui.uptoolkit.com/layouts/public-shell.md
Signed-out marketing shell with a top nav, sign-in actions, and a footer around a content slot.
# Public Shell
Signed-out marketing shell with a top nav, sign-in actions, and a footer around a content slot.
## Installation
```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/public-shell.json
```
[Registry JSON](https://ui.uptoolkit.com/r/public-shell.json)
## Preview
```tsx
import { PublicShell } from "@/components/public-shell";
export function Preview() {
return (
Page content goes here
Marketing and landing page content renders in the shell's content slot.
);
}
```
## Source
### components/public-shell.tsx
```tsx
"use client";
import { IconMenu2 } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
type PublicShellNavItem = {
label: string;
href: string;
/** Marks the current route. The shell does not do routing itself. */
active?: boolean;
};
type PublicShellFooterSection = {
heading: string;
links: readonly { label: string; href: string }[];
};
type PublicShellProps = {
brand?: { name: string; href?: string };
nav?: readonly PublicShellNavItem[];
signInHref?: string;
signUpHref?: string;
/** Link columns rendered above the footer note. Omit to skip the grid. */
footerSections?: readonly PublicShellFooterSection[];
/** Rendered at the bottom of the footer. Defaults to a rights line. */
footerNote?: React.ReactNode;
children?: React.ReactNode;
className?: string;
};
const defaultNav: readonly PublicShellNavItem[] = [
{ label: "Product", href: "#" },
{ label: "Pricing", href: "#" },
{ label: "Docs", href: "#" },
];
/**
* Shell for signed-out marketing pages: a header with nav and sign-in/sign-up
* links, a `children` content slot, and a link-column footer.
*
* Nav items and footer links are plain anchors, not a router `Link` — swap
* them for your router's link component if you need client-side navigation.
*/
function PublicShell({
brand = { name: "Uptoolkit", href: "#" },
nav = defaultNav,
signInHref = "#",
signUpHref = "#",
footerSections,
footerNote,
children,
className,
}: PublicShellProps) {
const [mobileOpen, setMobileOpen] = React.useState(false);
return (