···11# atproto-ui
2233-atproto-ui is a component library and set of hooks for rendering records from the AT Protocol (Bluesky, Leaflet, and friends) in React applications. It handles DID resolution, PDS endpoint discovery, and record fetching so you can focus on UI. [Live demo](https://atproto-ui.wisp.place).
33+A React component library for rendering AT Protocol records (Bluesky, Leaflet, Tangled, and more). Handles DID resolution, PDS discovery, and record fetching automatically. [Live demo](https://atproto-ui.wisp.place).
4455## Screenshots
66···991010## Features
11111212-- Drop-in components for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, etc.).
1313-- Pass prefetched data directly to components to skip API calls—perfect for server-side rendering, caching, or when you already have the data.
1414-- Hooks and helpers for composing your own renderers for your own applications, (PRs welcome!)
1515-- Built on the lightweight [`@atcute/*`](https://github.com/atcute) clients.
1212+- **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, `LeafletDocument`)
1313+- **Prefetch support** - Pass data directly to skip API calls (perfect for SSR/caching)
1414+- **Customizable theming** - Override CSS variables to match your app's design
1515+- **Composable hooks** - Build custom renderers with protocol primitives
1616+- Built on lightweight [`@atcute/*`](https://tangled.org/@mary.my.id/atcute) clients
16171718## Installation
1819···2021npm install atproto-ui
2122```
22232323-## Quick start
2424-2525-1. Wrap your app (once) with the `AtProtoProvider`.
2626-2. Drop any of the ready-made components inside that provider.
2727-3. Use the hooks to prefetch handles, blobs, or latest records when you want to control the render flow yourself.
2424+## Quick Start
28252926```tsx
3030-import { AtProtoProvider, BlueskyPost } from "atproto-ui";
2727+import { AtProtoProvider, BlueskyPost, LeafletDocument } from "atproto-ui";
31283229export function App() {
3330 return (
3431 <AtProtoProvider>
3532 <BlueskyPost did="did:plc:example" rkey="3k2aexample" />
3636- {/* you can use handles in the components as well. */}
3333+ {/* You can use handles too */}
3734 <LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
3835 </AtProtoProvider>
3936 );
4037}
4138```
42394343-## Passing prefetched data to skip API calls
4040+**Note:** The library automatically imports the CSS when you import any component. If you prefer to import it explicitly (e.g., for better IDE support or control over load order), you can use `import "atproto-ui/styles.css"`.
4141+4242+## Theming
4343+4444+Components use CSS variables for theming. By default, they respond to system dark mode preferences, or you can set a theme explicitly:
4545+4646+```tsx
4747+// Set theme via data attribute on document element
4848+document.documentElement.setAttribute("data-theme", "dark"); // or "light"
4949+5050+// For system preference (default)
5151+document.documentElement.removeAttribute("data-theme");
5252+```
5353+5454+### Available CSS Variables
5555+5656+```css
5757+--atproto-color-bg
5858+--atproto-color-bg-elevated
5959+--atproto-color-text
6060+--atproto-color-text-secondary
6161+--atproto-color-border
6262+--atproto-color-link
6363+/* ...and more */
6464+```
6565+6666+### Override Component Theme
44674545-All components accept a `record` prop. When provided, the component uses your data immediately without making network requests for that record. This is perfect for SSR, caching strategies, or when you've already fetched data through other means.
6868+Wrap any component in a div with custom CSS variables to override its appearance:
6969+7070+```tsx
7171+<div style={{
7272+ '--atproto-color-bg': '#f0f0f0',
7373+ '--atproto-color-text': '#000',
7474+ '--atproto-color-link': '#0066cc',
7575+}}>
7676+ <BlueskyPost did="..." rkey="..." />
7777+</div>
7878+```
7979+8080+## Prefetched Data
8181+8282+All components accept a `record` prop. When provided, the component uses your data immediately without making network requests. Perfect for SSR, caching, or when you've already fetched data.
46834784```tsx
4885import { BlueskyPost, useLatestRecord } from "atproto-ui";
···63100};
64101```
651026666-The same pattern works for all components:
103103+All components support prefetched data:
6710468105```tsx
6969-// BlueskyProfile with prefetched data
70106<BlueskyProfile did={did} record={profileRecord} />
7171-7272-// TangledString with prefetched data
73107<TangledString did={did} rkey={rkey} record={stringRecord} />
7474-7575-// LeafletDocument with prefetched data
76108<LeafletDocument did={did} rkey={rkey} record={documentRecord} />
77109```
781107979-### Available building blocks
111111+## API Reference
801128181-| Component / Hook | What it does |
8282-| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
8383-| `AtProtoProvider` | Configures PLC directory (defaults to `https://plc.directory`) and shares protocol clients via React context. |
8484-| `AtProtoRecord` | Core component that fetches and renders any AT Protocol record. **Accepts a `record` prop to use prefetched data and skip API calls.** |
8585-| `BlueskyProfile` | Renders a profile card for a DID/handle. **Accepts a `record` prop to skip fetching.** Also supports `fallback`, `loadingIndicator`, `renderer`, and `colorScheme`. |
8686-| `BlueskyPost` / `BlueskyQuotePost` | Shows a single Bluesky post with quotation support. **Accepts a `record` prop to skip fetching.** Custom renderer overrides and loading/fallback knobs available. |
8787-| `BlueskyPostList` | Lists the latest posts with built-in pagination (defaults: 5 per page, pagination controls on). |
8888-| `TangledString` | Renders a Tangled string (gist-like record). **Accepts a `record` prop to skip fetching.** Optional renderer overrides available. |
8989-| `LeafletDocument` | Displays long-form Leaflet documents with blocks and theme support. **Accepts a `record` prop to skip fetching.** Renderer overrides available. |
9090-| `useDidResolution`, `useLatestRecord`, `usePaginatedRecords`, … | Hook-level access to records. `useLatestRecord` returns both the `record` and `rkey` so you can pass them directly to components. |
113113+### Components
911149292-All components accept a `colorScheme` of `'light' | 'dark' | 'system'` so they can blend into your design. They also accept `fallback` and `loadingIndicator` props to control what renders before or during network work, and most expose a `renderer` override when you need total control of the final markup.
115115+| Component | Description |
116116+|-----------|-------------|
117117+| `AtProtoProvider` | Context provider for sharing protocol clients. Optional `plcDirectory` prop. |
118118+| `AtProtoRecord` | Core component for fetching/rendering any AT Protocol record. Accepts `record` prop. |
119119+| `BlueskyProfile` | Profile card for a DID/handle. Accepts `record`, `fallback`, `loadingIndicator`, `renderer`. |
120120+| `BlueskyPost` | Single Bluesky post. Accepts `record`, `iconPlacement`, custom renderers. |
121121+| `BlueskyQuotePost` | Post with quoted post support. Accepts `record`. |
122122+| `BlueskyPostList` | Paginated list of posts (default: 5 per page). |
123123+| `TangledString` | Tangled string (code snippet) renderer. Accepts `record`. |
124124+| `LeafletDocument` | Long-form document with blocks. Accepts `record`, `publicationRecord`. |
931259494-### Using hooks to fetch data once
126126+### Hooks
127127+128128+| Hook | Returns |
129129+|------|---------|
130130+| `useDidResolution(did)` | `{ did, handle, loading, error }` |
131131+| `useLatestRecord(did, collection)` | `{ record, rkey, loading, error, empty }` |
132132+| `usePaginatedRecords(options)` | `{ records, loading, hasNext, loadNext, ... }` |
133133+| `useBlob(did, cid)` | `{ url, loading, error }` |
134134+| `useAtProtoRecord(did, collection, rkey)` | `{ record, loading, error }` |
135135+136136+## Advanced Usage
951379696-`useLatestRecord` gives you the most recent record for any collection along with its `rkey`. You can pass both to components to skip the fetch:
138138+### Using Hooks for Custom Logic
9713998140```tsx
99141import { useLatestRecord, BlueskyPost } from "atproto-ui";
···114156};
115157```
116158117117-The same pattern works for other components. Just swap the collection NSID and component:
159159+### Custom Renderer
118160119119-```tsx
120120-const LatestLeafletDocument: React.FC<{ did: string }> = ({ did }) => {
121121- const { record, rkey } = useLatestRecord(did, "pub.leaflet.document");
122122- return record && rkey ? (
123123- <LeafletDocument did={did} rkey={rkey} record={record} colorScheme="light" />
124124- ) : null;
125125-};
126126-```
127127-128128-## Compose your own component
129129-130130-The helpers let you stitch together custom experiences without reimplementing protocol plumbing. The example below pulls a creator's latest post and renders a minimal summary:
161161+Use `AtProtoRecord` with a custom renderer for full control:
131162132163```tsx
133133-import { useLatestRecord, useColorScheme, AtProtoRecord } from "atproto-ui";
164164+import { AtProtoRecord } from "atproto-ui";
134165import type { FeedPostRecord } from "atproto-ui";
135166136136-const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => {
137137- const scheme = useColorScheme("system");
138138- const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(
139139- did,
140140- "app.bsky.feed.post",
141141- );
142142-143143- if (loading) return <span>Loading…</span>;
144144- if (error || !rkey) return <span>No post yet.</span>;
145145-146146- return (
147147- <AtProtoRecord<FeedPostRecord>
148148- did={did}
149149- collection="app.bsky.feed.post"
150150- rkey={rkey}
151151- renderer={({ record }) => (
152152- <article data-color-scheme={scheme}>
153153- <strong>{record?.text ?? "Empty post"}</strong>
154154- </article>
155155- )}
156156- />
157157- );
158158-};
167167+<AtProtoRecord<FeedPostRecord>
168168+ did={did}
169169+ collection="app.bsky.feed.post"
170170+ rkey={rkey}
171171+ renderer={({ record, loading, error }) => (
172172+ <article>
173173+ <strong>{record?.text ?? "Empty post"}</strong>
174174+ </article>
175175+ )}
176176+/>
159177```
160178161161-There is a [demo](https://atproto-ui.wisp.place/) where you can see the components in live action.
179179+## Demo
180180+181181+Check out the [live demo](https://atproto-ui.wisp.place/) to see all components in action.
162182163163-## Running the demo locally
183183+### Running Locally
164184165185```bash
166186npm install
167187npm run dev
168188```
169189170170-Then open the printed Vite URL and try entering a Bluesky handle to see the components in action.
190190+## Contributing
171191172172-## Next steps
192192+Contributions are welcome! Open an issue or PR for:
193193+- New record type support (e.g., Grain.social photos)
194194+- Improved documentation
195195+- Bug fixes or feature requests
173196174174-- Expand renderer coverage (e.g., Grain.social photos).
175175-- Expand documentation with TypeScript API references and theming guidelines.
197197+## License
176198177177-Contributions and ideas are welcome—feel free to open an issue or PR!
199199+MIT
+4-14
lib/components/BlueskyPost.tsx
···3939 * React node displayed while the post fetch is actively loading.
4040 */
4141 loadingIndicator?: React.ReactNode;
4242- /**
4343- * Preferred color scheme to pass through to renderers.
4444- */
4545- colorScheme?: "light" | "dark" | "system";
4242+4643 /**
4744 * Whether the default renderer should show the Bluesky icon.
4845 * Defaults to `true`.
···8380 * Resolved URL for the author's avatar blob, if available.
8481 */
8582 avatarUrl?: string;
8686- /**
8787- * Preferred color scheme bubbled down to children.
8888- */
8989- colorScheme?: "light" | "dark" | "system";
8383+9084 /**
9185 * Placement strategy for the Bluesky icon.
9286 */
···117111 * @param renderer - Optional renderer component to override the default.
118112 * @param fallback - Node rendered before the first fetch attempt resolves.
119113 * @param loadingIndicator - Node rendered while the post is loading.
120120- * @param colorScheme - Preferred color scheme forwarded to downstream components.
121114 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`.
122115 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
123116 * @returns A component that renders loading/fallback states and the resolved post.
124117 */
125125-export const BlueskyPost: React.FC<BlueskyPostProps> = ({
118118+export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo(({
126119 did: handleOrDid,
127120 rkey,
128121 record,
129122 renderer,
130123 fallback,
131124 loadingIndicator,
132132- colorScheme,
133125 showIcon = true,
134126 iconPlacement = "timestamp",
135127}) => {
···176168 authorHandle={authorHandle}
177169 authorDid={repoIdentifier}
178170 avatarUrl={avatarUrl}
179179- colorScheme={colorScheme}
180171 iconPlacement={iconPlacement}
181172 showIcon={showIcon}
182173 atUri={atUri}
···191182 avatarCid,
192183 avatarCdnUrl,
193184 authorHandle,
194194- colorScheme,
195185 iconPlacement,
196186 showIcon,
197187 atUri,
···230220 loadingIndicator={loadingIndicator}
231221 />
232222 );
233233-};
223223+});
234224235225export default BlueskyPost;
···11-import { useEffect, useState } from "react";
22-33-/**
44- * Possible user-facing color scheme preferences.
55- */
66-export type ColorSchemePreference = "light" | "dark" | "system";
77-88-const MEDIA_QUERY = "(prefers-color-scheme: dark)";
99-1010-/**
1111- * Resolves a persisted preference into an explicit light/dark value.
1212- *
1313- * @param pref - Stored preference value (`light`, `dark`, or `system`).
1414- * @returns Explicit light/dark scheme suitable for rendering.
1515- */
1616-function resolveScheme(pref: ColorSchemePreference): "light" | "dark" {
1717- if (pref === "light" || pref === "dark") return pref;
1818- if (
1919- typeof window === "undefined" ||
2020- typeof window.matchMedia !== "function"
2121- ) {
2222- return "light";
2323- }
2424- return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light";
2525-}
2626-2727-/**
2828- * React hook that returns the effective light/dark scheme, respecting system preferences.
2929- *
3030- * @param preference - User preference; defaults to following the OS setting.
3131- * @returns {'light' | 'dark'} Explicit scheme that should be used for rendering.
3232- */
3333-export function useColorScheme(
3434- preference: ColorSchemePreference = "system",
3535-): "light" | "dark" {
3636- const [scheme, setScheme] = useState<"light" | "dark">(() =>
3737- resolveScheme(preference),
3838- );
3939-4040- useEffect(() => {
4141- if (preference === "light" || preference === "dark") {
4242- setScheme(preference);
4343- return;
4444- }
4545- if (
4646- typeof window === "undefined" ||
4747- typeof window.matchMedia !== "function"
4848- ) {
4949- setScheme("light");
5050- return;
5151- }
5252- const media = window.matchMedia(MEDIA_QUERY);
5353- const update = (event: MediaQueryListEvent | MediaQueryList) => {
5454- setScheme(event.matches ? "dark" : "light");
5555- };
5656- update(media);
5757- if (typeof media.addEventListener === "function") {
5858- media.addEventListener("change", update);
5959- return () => media.removeEventListener("change", update);
6060- }
6161- media.addListener(update);
6262- return () => media.removeListener(update);
6363- }, [preference]);
6464-6565- return scheme;
6666-}
+5-1
lib/hooks/useDidResolution.ts
···9393 }
9494 } catch (e) {
9595 if (!cancelled) {
9696- setError(e as Error);
9696+ const newError = e as Error;
9797+ // Only update error if message changed (stabilize reference)
9898+ setError(prevError =>
9999+ prevError?.message === newError.message ? prevError : newError
100100+ );
97101 }
98102 } finally {
99103 if (!cancelled) setLoading(false);
+5-1
lib/hooks/usePdsEndpoint.ts
···4545 })
4646 .catch((e) => {
4747 if (cancelled) return;
4848- setError(e as Error);
4848+ const newError = e as Error;
4949+ // Only update error if message changed (stabilize reference)
5050+ setError(prevError =>
5151+ prevError?.message === newError.message ? prevError : newError
5252+ );
4953 })
5054 .finally(() => {
5155 if (!cancelled) setLoading(false);
+3-2
lib/index.ts
···11// Master exporter for the AT React component library.
2233+// Global styles - import this in your app root
44+import "./styles.css";
55+36// Providers & core primitives
47export * from "./providers/AtProtoProvider";
58export * from "./core/AtProtoRecord";
···1013export * from "./components/BlueskyPostList";
1114export * from "./components/BlueskyProfile";
1215export * from "./components/BlueskyQuotePost";
1313-export * from "./components/ColorSchemeToggle";
1416export * from "./components/LeafletDocument";
1517export * from "./components/TangledString";
1618···1921export * from "./hooks/useBlob";
2022export * from "./hooks/useBlueskyAppview";
2123export * from "./hooks/useBlueskyProfile";
2222-export * from "./hooks/useColorScheme";
2324export * from "./hooks/useDidResolution";
2425export * from "./hooks/useLatestRecord";
2526export * from "./hooks/usePaginatedRecords";