A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1# atproto-ui
2
3A 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).
4
5## Screenshots
6
7
8
9
10## Features
11
12- **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, `LeafletDocument`)
13- **Prefetch support** - Pass data directly to skip API calls (perfect for SSR/caching)
14- **Customizable theming** - Override CSS variables to match your app's design
15- **Composable hooks** - Build custom renderers with protocol primitives
16- Built on lightweight [`@atcute/*`](https://tangled.org/@mary.my.id/atcute) clients
17
18## Installation
19
20```bash
21npm install atproto-ui
22```
23
24## Quick Start
25
26```tsx
27import { AtProtoProvider, BlueskyPost, LeafletDocument } from "atproto-ui";
28import "atproto-ui/styles.css";
29
30export function App() {
31 return (
32 <AtProtoProvider>
33 <BlueskyPost did="did:plc:example" rkey="3k2aexample" />
34 {/* You can use handles too */}
35 <LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
36 </AtProtoProvider>
37 );
38}
39```
40
41**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"`.
42
43## Theming
44
45Components use CSS variables for theming. By default, they respond to system dark mode preferences, or you can set a theme explicitly:
46
47```tsx
48// Set theme via data attribute on document element
49document.documentElement.setAttribute("data-theme", "dark"); // or "light"
50
51// For system preference (default)
52document.documentElement.removeAttribute("data-theme");
53```
54
55### Available CSS Variables
56
57```css
58--atproto-color-bg
59--atproto-color-bg-elevated
60--atproto-color-text
61--atproto-color-text-secondary
62--atproto-color-border
63--atproto-color-link
64/* ...and more, check out lib/styles.css */
65```
66
67### Override Component Theme
68
69Wrap any component in a div with custom CSS variables to override its appearance:
70
71```tsx
72import { AtProtoStyles } from "atproto-ui";
73
74<div style={{
75 '--atproto-color-bg': '#f0f0f0',
76 '--atproto-color-text': '#000',
77 '--atproto-color-link': '#0066cc',
78} satisfies AtProtoStyles}>
79 <BlueskyPost did="..." rkey="..." />
80</div>
81```
82
83## Prefetched Data
84
85All 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.
86
87```tsx
88import { BlueskyPost, useLatestRecord } from "atproto-ui";
89import type { FeedPostRecord } from "atproto-ui";
90
91const MyComponent: React.FC<{ did: string }> = ({ did }) => {
92 // Fetch the latest post using the hook
93 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>(
94 did,
95 "app.bsky.feed.post"
96 );
97
98 if (loading) return <p>Loading…</p>;
99 if (!record || !rkey) return <p>No posts found.</p>;
100
101 // Pass the fetched record directly—BlueskyPost won't re-fetch it
102 return <BlueskyPost did={did} rkey={rkey} record={record} />;
103};
104```
105
106All components support prefetched data:
107
108```tsx
109<BlueskyProfile did={did} record={profileRecord} />
110<TangledString did={did} rkey={rkey} record={stringRecord} />
111<LeafletDocument did={did} rkey={rkey} record={documentRecord} />
112```
113
114### Using atcute Directly
115
116Use atcute directly to construct records and pass them to components—fully compatible!
117
118```tsx
119import { Client, simpleFetchHandler, ok } from '@atcute/client';
120import type { AppBskyFeedPost } from '@atcute/bluesky';
121import { BlueskyPost } from 'atproto-ui';
122
123// Create atcute client
124const client = new Client({
125 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
126});
127
128// Fetch a record
129const data = await ok(
130 client.get('com.atproto.repo.getRecord', {
131 params: {
132 repo: 'did:plc:ttdrpj45ibqunmfhdsb4zdwq',
133 collection: 'app.bsky.feed.post',
134 rkey: '3m45rq4sjes2h'
135 }
136 })
137);
138
139const record = data.value as AppBskyFeedPost.Main;
140
141// Pass atcute record directly to component!
142<BlueskyPost
143 did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"
144 rkey="3m45rq4sjes2h"
145 record={record}
146/>
147```
148
149## API Reference
150
151### Components
152
153| Component | Description |
154|-----------|-------------|
155| `AtProtoProvider` | Context provider for sharing protocol clients. Optional `plcDirectory` prop. |
156| `AtProtoRecord` | Core component for fetching/rendering any AT Protocol record. Accepts `record` prop. |
157| `BlueskyProfile` | Profile card for a DID/handle. Accepts `record`, `fallback`, `loadingIndicator`, `renderer`. |
158| `BlueskyPost` | Single Bluesky post. Accepts `record`, `iconPlacement`, custom renderers. |
159| `BlueskyQuotePost` | Post with quoted post support. Accepts `record`. |
160| `BlueskyPostList` | Paginated list of posts (default: 5 per page). |
161| `TangledString` | Tangled string (code snippet) renderer. Accepts `record`. |
162| `LeafletDocument` | Long-form document with blocks. Accepts `record`, `publicationRecord`. |
163
164### Hooks
165
166| Hook | Returns |
167|------|---------|
168| `useDidResolution(did)` | `{ did, handle, loading, error }` |
169| `useLatestRecord(did, collection)` | `{ record, rkey, loading, error, empty }` |
170| `usePaginatedRecords(options)` | `{ records, loading, hasNext, loadNext, ... }` |
171| `useBlob(did, cid)` | `{ url, loading, error }` |
172| `useAtProtoRecord(did, collection, rkey)` | `{ record, loading, error }` |
173
174## Advanced Usage
175
176### Using Hooks for Custom Logic
177
178```tsx
179import { useLatestRecord, BlueskyPost } from "atproto-ui";
180import type { FeedPostRecord } from "atproto-ui";
181
182const LatestBlueskyPost: React.FC<{ did: string }> = ({ did }) => {
183 const { record, rkey, loading, error, empty } = useLatestRecord<FeedPostRecord>(
184 did,
185 "app.bsky.feed.post",
186 );
187
188 if (loading) return <p>Fetching latest post…</p>;
189 if (error) return <p>Could not load: {error.message}</p>;
190 if (empty || !record || !rkey) return <p>No posts yet.</p>;
191
192 // Pass both record and rkey—no additional API call needed
193 return <BlueskyPost did={did} rkey={rkey} record={record} colorScheme="system" />;
194};
195```
196
197### Custom Renderer
198
199Use `AtProtoRecord` with a custom renderer for full control:
200
201```tsx
202import { AtProtoRecord } from "atproto-ui";
203import type { FeedPostRecord } from "atproto-ui";
204
205<AtProtoRecord<FeedPostRecord>
206 did={did}
207 collection="app.bsky.feed.post"
208 rkey={rkey}
209 renderer={({ record, loading, error }) => (
210 <article>
211 <strong>{record?.text ?? "Empty post"}</strong>
212 </article>
213 )}
214/>
215```
216
217## Demo
218
219Check out the [live demo](https://atproto-ui.netlify.app/) to see all components in action.
220
221### Running Locally
222
223```bash
224npm install
225npm run dev
226```
227
228## Contributing
229
230Contributions are welcome! Open an issue or PR for:
231- New record type support (e.g., Grain.social photos)
232- Improved documentation
233- Bug fixes or feature requests
234
235## License
236
237MIT