Interactive component reference
Interactive examples of all WeWrite components with their states and variants
app/components/ui/accordion.tsx
Expandable content sections built on Radix UI. Supports single or multiple open items with animated transitions.
app/components/activity/ActivityCard.tsx
Displays page edit activity with diff preview, author info, and allocation controls. Hover over sections to see their names.
border-borderbg-neutral-alpha-dark-10 (additive white overlay)Added text:
+new contentRemoved text:
-old contentContext:
...surrounding text...Code Usage
<ActivityCard
activity={{
pageId: "abc123",
pageName: "Example Page",
userId: "user123",
username: "jamie",
timestamp: "2024-01-15T10:30:00Z",
currentContent: "...",
previousContent: "...",
diff: { added: 42, removed: 8, hasChanges: true }
}}
isCarousel={false}
compactLayout={false}
/>@/components/ui/alert, @/components/utils/UnifiedModal, @/hooks/useConfirmation
Complete system for user feedback: inline alerts for status messages and confirmation modals to replace window.confirm(). Always use custom modals instead of browser alerts.
Always use useConfirmation hook + ConfirmationModal instead of browser's window.confirm(). This ensures consistent styling and UX across the app.
Step 1: Import and Initialize
import { useConfirmation } from '@/hooks/useConfirmation';
import { ConfirmationModal } from '@/components/utils/UnifiedModal';
// In your component:
const { confirmationState, confirm, closeConfirmation } = useConfirmation();Step 2: Use confirm() (returns Promise<boolean>)
// Generic confirmation
const handleDelete = async () => {
const confirmed = await confirm({
title: "Delete Page?",
message: "Are you sure you want to delete this page?",
confirmText: "Delete",
cancelText: "Cancel",
variant: "destructive" // or "warning" | "default"
});
if (confirmed) {
// User clicked confirm
await deletePage();
}
};
// Convenience methods available:
const confirmed = await confirmDelete("this item");
const confirmed = await confirmLogout();
const confirmed = await confirmCancelSubscription();Step 3: Render the Modal
// In your JSX (typically near the end of the component):
<ConfirmationModal
isOpen={confirmationState.isOpen}
onClose={closeConfirmation}
onConfirm={confirmationState.onConfirm}
title={confirmationState.title}
message={confirmationState.message}
confirmText={confirmationState.confirmText}
cancelText={confirmationState.cancelText}
type={confirmationState.variant}
/>Yes/No decisions
variant="confirm"Info with OK button
variant="alert"Text input required
variant="prompt"Multiple action buttons
variant="action"Use: UnifiedModal components
Use: Modal or AdaptiveModal
Component Usage
// UnifiedModal uses Dialog component internally (always centered)
// For alerts/confirmations, use UnifiedModal or its wrappers:
import { ConfirmationModal } from '@/components/utils/UnifiedModal';
<ConfirmationModal
isOpen={true}
onClose={handleClose}
onConfirm={handleConfirm}
title="Delete Page?"
message="Are you sure?"
type="destructive"
/>
// For complex forms/content, use Modal or AdaptiveModal insteadapp/components/payments/AllocationControls.tsx
Interactive allocation interface with plus/minus buttons and visual composition bar. Features particle animations on allocation increases.
Click + to allocate. Keep clicking past 80% to see overfunded (amber) state.
Normal allocation (30% allocated, 50% available)
Nearly full (70% allocated, 10% available)
Fully allocated (80% allocated, 0% available)
Overfunded (80% funded + 20% overfunded)
app/components/ui/AnimatedStack.tsx
Standardized animation system for elements entering and exiting the layout. Prevents layout shifts with smooth height transitions.
{
"duration": 0.2,
"ease": "easeOut"
}Stack items (gap: 8px, preset: default)
Content before
Animated content
This smoothly animates in and out
Content after
Main content that stays in place while the banner animates above
AnimatedStack (multiple items)
import { AnimatedStack, AnimatedStackItem } from '@/components/ui/AnimatedStack';
<AnimatedStack gap={12} preset="default">
{items.map(item => (
<AnimatedStackItem key={item.id}>
<Card>{item.content}</Card>
</AnimatedStackItem>
))}
</AnimatedStack>AnimatedPresenceItem (single toggle)
import { AnimatedPresenceItem } from '@/components/ui/AnimatedStack';
<AnimatedPresenceItem
show={showError}
gap={12}
preset="gentleSpring"
gapPosition="top"
>
<ErrorBanner message="Something went wrong" />
</AnimatedPresenceItem>Direct Framer Motion usage
import { AnimatePresence, motion } from 'framer-motion';
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, height: 0, marginTop: 0 }}
animate={{ opacity: 1, height: 'auto', marginTop: 12 }}
exit={{ opacity: 0, height: 0, marginTop: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="overflow-hidden"
>
<Button>Animated Button</Button>
</motion.div>
)}
</AnimatePresence>default
{
"duration": 0.2,
"ease": "easeOut"
}fast
{
"duration": 0.15,
"ease": "easeOut"
}slow
{
"duration": 0.3,
"ease": "easeInOut"
}spring
{
"type": "spring",
"stiffness": 500,
"damping": 30
}gentleSpring
{
"type": "spring",
"stiffness": 300,
"damping": 25
}app/components/ui/avatar.tsx
User avatar component built on Radix UI. Supports image with automatic fallback to initials or icon when the image fails to load.
app/components/ui/badge.tsx
Interactive status indicators and labels. NOTE: 'Chips' do not exist in our design system - use Badge for all pill-shaped indicators. In 'Shiny' UI mode (Settings > Appearance), badges automatically get skeuomorphic styling with shimmer effects on hover.
Tailwind CSS classes
Border and separator patterns for visual hierarchy and content organization. Use border-border for standard borders that adapt to theme.
border-border - Standard border color that adapts to light/dark theme. Use for card edges, dividers, and input borders.
border-b border-border - Bottom border for horizontal separators between sections.
divide-y divide-border - Apply to parent to add borders between child elements (great for lists).
ring-1 ring-border - Subtle outline effect, useful for focus states or grouping.
Standard <hr /> element
border-b border-border (on a div)
Content above the separator
Content below the separator
border-b border-dashed border-border
Dashed separator - useful for less prominent divisions
border border-border rounded-lg
Standard card border
ring-1 ring-border rounded-lg
Ring border (sharper)
border-2 border-border
Thicker border for emphasis
border + shadow-sm
Combined for depth
Apply divide-y divide-border to parent element
List Item 1
Description text
List Item 2
Description text
List Item 3
Description text
border-b border-border pb-4 on container
Use border-l or border-r for vertical dividers
24
Posts
128
Followers
$42
Earned
Borders for interactive elements
hover:border-primary
border-2 border-primary (selected)
ring-2 ring-primary ring-offset-2
app/styles/card-theme.css
Glassmorphic container with backdrop blur and subtle border
The .wewrite-card class provides glassmorphism with semi-transparent background, backdrop blur, and subtle border. This is the only card class you need for most cases.
Edge-to-edge content:
Compact cards:
Used for: dropdowns, popovers, text selection menus, allocation bar
wewrite-card-sharp + wewrite-card-border-bottom + wewrite-card-no-padding
Used for: UserProfileHeader, FinancialHeader
Add hover:bg-[var(--card-bg-hover)] for clickable cards
Default: Just use .wewrite-card
Edge-to-edge content: Add wewrite-card-no-padding
Floating UI: Add wewrite-floating
Fixed headers: Combine wewrite-card-sharp + wewrite-card-border-bottom
app/components/ui/sparkline.tsx, app/components/utils/SimpleSparkline.tsx, app/components/admin/Sparkline.tsx
Visualization components for displaying trends, metrics, and data over time. We have multiple sparkline implementations - this section helps identify opportunities for consolidation.
Default (primary color)
Custom height (30px)
Thick stroke (2.5px)
Default (primary color)
Sparse data (zero values)
Larger (60px height)
Upward trend (green)
Downward trend (red)
Neutral trend (gray)
All three implementations rendering the same 12-point dataset. Note the different visual styles and features.
SimpleSparkline
Line with area fill, SVG-based
ui/Sparkline
Bar chart, SVG-based
admin/Sparkline
Bar chart, Recharts, trend dot
SimpleSparkline - Best for activity/engagement trends, 24-hour data. Lightweight SVG.
ui/Sparkline - Best for discrete values, handles zero values well. Lightweight SVG.
admin/Sparkline - Best for admin dashboards with trend indicators. Uses Recharts (heavier).
app/components/settings/ColorSystemManager.tsx
Adjust accent, neutral, and background colors to test how all components look with different color schemes
Set different accent colors for light and dark themes
Choose a color or upload a custom image
app/globals.css
How to use color tokens in Tailwind classes throughout the codebase
DO NOT use Tailwind's arbitrary opacity syntax (slash notation) with our color tokens. OKLCH colors don't support Tailwind's color/opacity modifier.
❌ Wrong (invisible/broken)
bg-primary/10text-success/50ring-error/30✓ Correct (use hyphen)
bg-primary-10text-success-50ring-error-30Why? Our colors use oklch(var(--primary)) CSS variables. Tailwind's /10 syntax appends opacity to the color value, but OKLCH's variable-based syntax breaks when modified. We define opacity variants explicitly in tailwind.config.ts (e.g., primary-10: "oklch(var(--primary) / 0.10)").
The primary color is used for interactive elements, links, and emphasis. Use opacity variants for subtle fills.
bg-primarybg-primary-20bg-primary-10bg-primary-5Solid colors are opaque fills derived from the primary hue with low chroma (oklch). Use neutral-solid-{N} when you need a consistent, opaque background that doesn't allow content behind it to show through.
bg-neutral-solid-30bg-neutral-solid-20bg-neutral-solid-15bg-neutral-solid-10bg-neutral-solid-5Alpha colors are transparent overlays (rgba) that adapt to light/dark mode. In light mode they use black, in dark mode they use white. Use neutral-alpha-{N}when you want content behind to show through or for hover/overlay effects.
bg-neutral-alpha-30bg-neutral-alpha-20bg-neutral-alpha-15bg-neutral-alpha-10bg-neutral-alpha-5Solid: Card backgrounds, buttons, chips - opaque fills that cover content
Alpha: Hover states, overlays, glassmorphism - transparent effects
Success and error colors with opacity variants for backgrounds and subtle fills.
bg-successbg-success-10bg-errorbg-error-10Solid buttons (primary/success/error): bg-primary hover:alpha-10 active:alpha-15
Secondary buttons: bg-neutral-solid-10 hover:alpha-10 active:alpha-15
Outline buttons: border border-neutral-alpha-20 hover:bg-neutral-alpha-5
Ghost buttons: hover:bg-neutral-alpha-5 active:bg-neutral-alpha-10
Cards: bg-card border border-border
Active chips: bg-primary-10 text-primary
Inactive chips: bg-neutral-solid-10 text-foreground
Success-secondary hover: bg-success-10 hover:success-alpha-10
Destructive-secondary hover: bg-error-10 hover:error-alpha-10
app/components/command-palette/CommandPalette.tsx
Keyboard-driven command palette for navigation, settings, and page actions. Triggered by /, Cmd+K, or Cmd+Shift+P.
app/components/pages/ContentPageView.tsx
The main page view component. For statistics cards, see Page Stats section.
| Element | My Page (Saved) | New Page | Others' Pages |
|---|---|---|---|
| Header Elements | |||
Page HeaderTitle and author info | |||
Editable TitleTitle input field vs read-only text | |||
Back ButtonBack arrow in header for new page mode | |||
| Save/Edit Controls | |||
Auto-Save IndicatorShows save status (pending/saving/saved/error) | |||
| Content Area | |||
Content DisplayMain content editor/viewer | |||
Content EditableContent is editable vs read-only | |||
Dense Mode ToggleToggle for compact text display | |||
Writing Ideas BannerTopic suggestions for new pagesOnly for new pages that are NOT replies | |||
| Footer/Metadata | |||
Page FooterActions and metadata fields | |||
Location FieldAdd/view location attachmentOnly visible if page has a location set | |||
Custom Date FieldSet/view custom dateOnly visible if page has a custom date set | |||
| Page Connections | |||
Page Graph ViewInteractive network visualization | |||
What Links HerePages that link to this pageOnly if there are backlinks (hides when empty) | |||
Reply To CardShows parent page if this is a replyOnly if page.replyTo exists | |||
Replies SectionShows all replies to this pageOn own pages: only visible if hasReplies (hides empty state). On others pages: always visible (shows CTA to create reply) | |||
Related Pages SectionPages by same author or linked | |||
| Bottom Actions | |||
Add to Page ButtonAdd this page to another page | |||
Delete ButtonDelete this page | |||
Cancel ButtonCancel creating new page | |||
| Floating Elements | |||
Allocation BarSupport/pledge bar at bottom | |||
Empty Lines AlertWarning about empty lines in contentOnly when editing and emptyLinesCount > 0 | |||
Deleted Page BannerShows when previewing deleted pageOnly when ?preview=deleted query param | |||
Source of Truth: This table is generated from app/config/contentPageVisibility.ts. Edit that file to change visibility rules.
Owners are always in edit mode. No toggle button.
app/components/ui/adaptive-modal.tsx
Use AdaptiveModal for responsive modals that automatically switch between Dialog (desktop) and Drawer (mobile). For specific use cases, individual Dialog, Drawer, and SideDrawer components are also available.
AdaptiveModal automatically renders as a centered Dialog on desktop and a bottom Drawer on mobile. It handles responsive behavior, URL hash tracking, and analytics out of the box.
Import and Usage
import { AdaptiveModal } from '@/components/ui/adaptive-modal';
<AdaptiveModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="Modal Title"
hashId="my-modal" // Optional: adds #my-modal to URL
analyticsId="my-modal" // Optional: tracks open/close
mobileHeight="85vh" // Optional: drawer height on mobile
className="sm:max-w-lg" // Optional: dialog width on desktop
>
<div className="space-y-4">
{/* Your content here */}
</div>
</AdaptiveModal>Responsive modal that adapts to screen size.
Best for: Most use cases
Centered modal for focused tasks.
Best for: Desktop-only confirmations
Bottom sheet with swipe gesture.
Best for: Mobile-first navigation
Full-height panel from left/right.
Best for: Detail views, editing forms
app/lib/emailTemplates.ts
Inline-styled HTML components used in email templates. These components are designed to work across email clients (Gmail, Apple Mail, Outlook) with proper fallbacks and dark mode support.
WeWrite |
This is a default card container used for most email content.
Success! Your payout has been processed.
Step 1 of 2: Verify your email address
We detected unusual activity on your account.
Your first earnings
$2.50
🔒 Safe and secure: We use Stripe—the same payment system used by millions of businesses—to make sure your money gets to you safely.
💡 Tips for a great username: Keep it memorable and easy to spell.
| Amount | $45.00 |
| Processed on | December 1, 2025 |
| Expected arrival | December 3-5, 2025 |
© 2026 WeWrite. All rights reserved.
WeWrite |
This is the moment that makes it all worth it. We've just sent $45.00 to your bank account—money you earned by sharing your words with the world.
| Amount | $45.00 |
| Processed on | December 1, 2025 |
| Expected arrival | December 3-5, 2025 |
This is what happens when readers believe in your work. Thank you for being part of WeWrite—keep writing, keep earning, keep being awesome.
© 2026 WeWrite. All rights reserved.
These CSS classes are used for dark mode support in email clients that support prefers-color-scheme:
.dark-textLight text color.dark-text-mutedMuted text color.dark-text-headingHeading text (white).dark-cardCard background.dark-card-innerNested card background.dark-footerFooter text/links.dark-linkLink color (blue).dark-stat-boxStats box background.dark-alert-securitySecurity alert stylingUse addEmailUtm() to add tracking parameters to all email links:
import { addEmailUtm } from '@/lib/emailTemplates';
// Usage
const trackedUrl = addEmailUtm(
'https://getwewrite.app/settings',
'verification-reminder', // utm_campaign
'verify_button' // utm_content (optional)
);
// Result:
// https://getwewrite.app/settings?utm_source=email&utm_medium=email&utm_campaign=verification-reminder&utm_content=verify_buttonapp/components/ui/EmptyState.tsx
Standardized empty state component for consistent messaging when content is unavailable. All empty states use a unified dotted border style.
Basic empty state
When you receive messages, they'll appear here.
With different icon
Add your first item to get started.
Default button
Create your first page to get started.
Outline button variant
Drag and drop files here or click to upload.
Small (sm)
Add alternative titles to help people find this page.
Medium (md) - Default
When you receive messages, they'll appear here.
Large (lg)
Create your first page to get started with WeWrite.
Projects help you organize your pages and collaborate with others.
Your recent edits and activity will show up here.
When people follow you, they'll appear in this list.
All empty states use the empty-state-border CSS class for consistent dashed borders. This class can also be used on custom components:
Custom element with empty-state-border class
Import
import EmptyState from '@/components/ui/EmptyState';
Basic Usage
<EmptyState icon="Inbox" title="No messages" description="When you receive messages, they'll appear here." />
With Action Button
<EmptyState
icon="FileText"
title="No pages yet"
description="Create your first page to get started."
action={{
label: "Create Page",
onClick: () => handleCreate(),
variant: 'outline' // optional
}}
/>Size Variants
// Small - for compact spaces <EmptyState size="sm" icon="Tags" title="No tags" description="..." /> // Medium (default) - standard usage <EmptyState size="md" icon="Inbox" title="No messages" description="..." /> // Large - for primary empty states <EmptyState size="lg" icon="Folder" title="No projects" description="..." />
icon: IconName- Required. Icon name from the Icon componenttitle: string- Required. Main heading textdescription: string- Required. Supporting description textsize?: 'sm' | 'md' | 'lg'- Optional. Controls padding and text size (default: 'md')action?: {label, onClick, variant?}- Optional. Action button configurationclassName?: string- Optional. Additional CSS classesapp/components/ui/
Interactive form elements including switches and checkboxes
app/components/ui/FullPageError.tsx
Full-screen error page shown when critical errors occur. Includes action buttons, collapsible error details, and copy-to-clipboard functionality.
error - Error object with message, stack, and optional digestreset - Function to reset the error boundarytitle - Custom title (default: "Something went wrong")message - Custom messageshowGoBack - Show "Go Back" button (default: true)showGoHome - Show "Back to Home" button (default: true)showTryAgain - Show "Try Again" button (default: true)onRetry - Custom retry functionError boundary usage
// In error.tsx or error boundary
import FullPageError from '@/components/ui/FullPageError';
export default function ErrorPage({
error,
reset
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<FullPageError
error={error}
reset={reset}
title="Page Error"
message="This page encountered an unexpected error."
showGoBack={true}
showGoHome={true}
showTryAgain={true}
/>
);
}app/components/pages/ContentPageHeader.tsx
Inline group picker for assigning pages to groups. Shows 'No group' when unassigned, or the group name as an indigo badge when assigned. Owners can click to open a dropdown to change or remove the group. Read-only viewers see a static badge. Gated behind the 'groups' feature flag.
app/components/ui/Icon.tsx
Phosphor icons with 3 variants: outline (regular weight),solid (fill weight), andanimated (framer-motion). Icons without animated variants show a dash.
Use AnimatedIconWrapper to add animations to any icon.
Use pulsing dots for notifications and status indicators (not loading states).
import { Icon } from '@/components/ui/Icon';
// Static (default)
<Icon name="Heart" size={20} />
// With animation wrapper
import { AnimatedIconWrapper } from '@/components/ui/animated-icons';
<AnimatedIconWrapper animate="pulse">
<Icon name="Heart" size={20} />
</AnimatedIconWrapper>app/components/ui/InlineError.tsx
Unified error, warning, and info display component with multiple variants and sizes
Small (sm)
Medium (md) - Default
Large (lg)
We couldn't load your data. This might be a temporary issue.
Unable to process your request.
sm: Inline form field errors, compact noticesmd: Standard error cards, form-level errorslg: Full page errors, error boundaries, critical alertsImport
import { InlineError } from '@/components/ui/InlineError';Basic Usage
<InlineError message="Something went wrong. Please try again." variant="error" size="md" />
With Retry Action
<InlineError
message="Failed to load page content."
variant="error"
size="md"
onRetry={() => refetch()}
retryLabel="Retry"
/>With Error Details
<InlineError
message="An unexpected error occurred."
variant="error"
size="lg"
title="Request Failed"
errorDetails="Error: 500 Internal Server Error\nRequest ID: abc-123-def"
showCopy={true}
showCollapsible={true}
onRetry={() => retry()}
/>message: string- Required. The main error/warning/info messagevariant?: 'error' | 'warning' | 'info'- Optional. Style variant (default: 'error')size?: 'sm' | 'md' | 'lg'- Optional. Size variant (default: 'md')title?: string- Optional. Title text for large sizesonRetry?: () => void- Optional. Callback for retry buttonretryLabel?: string- Optional. Custom retry button labelerrorDetails?: string- Optional. Technical error detailsshowCopy?: boolean- Optional. Show copy button for error detailsshowCollapsible?: boolean- Optional. Make error details collapsibleclassName?: string- Optional. Additional CSS classesapp/components/ui/input.tsx
Glassmorphic text input with focus states and validation
This field is required
This might cause issues
app/components/ui/LoadingState.tsx
Standardized loading states. Always use the default Loader style (no color classes) for consistency.
Small
Medium (default)
Large
<Icon name="Loader" className="animate-spin" /><Icon name="Loader" />The PulseLoader has built-in animation. Never wrap it in animate-spinor any spinning container — animation is atomic (inside the loader), not molecular (applied by a parent).
Loading state usage
// Default loader (PulseLoader) - always use default style
<Icon name="Loader" size={24} />
// Size variants
<Icon name="Loader" size={16} /> // small
<Icon name="Loader" size={32} /> // large
// WRONG - never add animate-spin to Loader (it already animates)
// <Icon name="Loader" className="animate-spin" />
// WRONG - don't use Loader2, use Loader instead
// <Icon name="Loader2" className="animate-spin" />
// Bordered loading state (matches EmptyState style)
<LoadingState
message="Loading content..."
showBorder
size="md"
minHeight="h-32"
/>
// Skeleton placeholders
<SkeletonLine width="w-3/4" />
<SkeletonCard />app/components/utils/NotificationItem.tsx
Displays various types of notifications with read/unread states, type icons, and action buttons.
username linked to your page Page Title
Unread (ring + full opacity icon + dot)
jamie linked to your page Nick Land from Competition
Read (no ring + faded icon + no dot)
jamie linked to your page Nick Land from Competition
Social
jamie linked to your page Nick Land from Competition
FRANTZ started following you
writer123 added your page My New Essay to Philosophy Notes
Payments & Billing
Payment Failed
Your subscription payment of $9.99 failed.
90% of monthly funds allocated
You've allocated $9.00 of $10.00. Top off your account or adjust allocations.
Payout Completed
Your payout of $25.00 has been deposited to your bank account.
System
New Feature: Groups
Collaborate with others using our new Groups feature. Create a group to start sharing.
Verify your email address
Please verify your email to access all features and ensure your account is secure.
Basic Usage
<NotificationItem
notification={{
id: "notif-123",
type: "link",
sourceUserId: "user-456",
targetPageTitle: "My Page",
sourcePageTitle: "Their Page",
createdAt: "2024-01-15T10:30:00Z",
read: false,
}}
/>app/components/ui/PageHeader.tsx
Standardized page header component ensuring consistent title size (text-2xl), spacing (mb-6), and layout across all pages.
Pages you've viewed recently
A collaborative group for creative writers
Import
import { PageHeader } from '@/components/ui/PageHeader';Page Structure (always wrap in NavPageLayout)
import NavPageLayout from '@/components/layout/NavPageLayout';
import { PageHeader } from '@/components/ui/PageHeader';
export default function MyPage() {
return (
<NavPageLayout>
<PageHeader title="My Page" />
{/* page content */}
</NavPageLayout>
);
}With Description
<PageHeader title="Recently Viewed" description="Pages you've viewed recently" />
With Back Button
// Uses router.back()
<PageHeader title="Settings" backHref={true} />
// Navigates to specific URL
<PageHeader title="Settings" backHref="/groups" />With Actions and Children
<PageHeader
title="Random Pages"
actions={<Button>Shuffle</Button>}
>
<div className="mt-4">
{/* Additional content below the header */}
</div>
</PageHeader>title: string- Required. Page heading textdescription?: string- Subtitle below the titleicon?: IconName- Icon displayed before the titleiconClassName?: string- CSS classes for the iconbackHref?: boolean | string- Back button (true = router.back(), string = push URL)actions?: ReactNode- Right-aligned buttons/controlsbadges?: ReactNode- Inline badges after the titlechildren?: ReactNode- Content below the title row (meta info, tabs)className?: string- Override wrapper classes (default: mb-6)app/components/ui/PageLinksCard.tsx
Unified card component for displaying lists of page links with filtering, load more, and custom rendering
Use pillCounter={true} to show count as a pill badge on the right (matches StatsCard pattern).
Use headerAction for filter/action buttons. Use subheader for expandable filter controls.
Set initialLimit to show fewer items with a "Load more" button.
No pages link here yet
Set hideWhenEmpty={false} and emptyMessage to show empty state.
| Prop | Type | Default | Description |
|---|---|---|---|
icon | IconName | required | Icon to display in header |
title | string | required | Title text for the header |
items | PageLinkItem[] | required | Array of page link items |
pillCounter | boolean | false | Show count as pill badge on right |
headerAction | ReactNode | - | Action button(s) in header right |
subheader | ReactNode | - | Content between header and items (filter controls) |
initialLimit | number | 8 | Items to show before "Load more" |
hideWhenEmpty | boolean | true | Hide card when no items |
emptyMessage | string | - | Message to show when empty |
renderItem | function | - | Custom item renderer |
footer | ReactNode | - | Footer content with separator |
Standard filter button implementation:
const [showFiltersRow, setShowFiltersRow] = useState(false);
const filterButton = (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setShowFiltersRow(!showFiltersRow)}
>
<Icon
name="SlidersHorizontal"
size={16}
className={showFiltersRow ? "text-primary" : "text-muted-foreground"}
/>
</Button>
);
<PageLinksCard
headerAction={filterButton}
subheader={<FilterRow show={showFiltersRow} />}
...
/>app/components/pages/UnifiedPageList.tsx
Shared page list component used across profiles, groups, and anywhere pages need to be displayed. Supports multiple view modes with a persistent toggle.
PillLink pills in a wrapped flex layout — the standard page list style.
One pill per line. Supports metadata display via the dropdown selector.
List view showing author byline on each pill.
List view showing created date on each pill.
List view showing page earnings on each pill.
Start writing your first page!
When isOwned=true, private page titles are visible instead of hidden.
import { UnifiedPageList, PageListViewToggle, ListMetadataSelector } from '@/components/pages/UnifiedPageList';
import type { PageItem, PageListView, ListMetadata } from '@/components/pages/UnifiedPageList';
// Basic usage — wrapped PillLinks (default)
<UnifiedPageList pages={pages} />
// With view toggle (controlled)
const [view, setView] = useState<PageListView>('wrapped');
const [metadata, setMetadata] = useState<ListMetadata>('none');
<PageListViewToggle view={view} onViewChange={setView} />
{view === 'list' && <ListMetadataSelector metadata={metadata} onMetadataChange={setMetadata} />}
<UnifiedPageList pages={pages} view={view} onViewChange={setView} listMetadata={metadata} onListMetadataChange={setMetadata} />
// With built-in view toggle
<UnifiedPageList pages={pages} showViewToggle />
// Show owned pages (private titles visible)
<UnifiedPageList pages={pages} isOwned={true} />
// Custom empty state props
<UnifiedPageList pages={[]} emptyIcon="PenLine" emptyTitle="No writing yet" emptyDescription="Create your first page!" />
// List view with specific metadata
<UnifiedPageList pages={pages} view="list" listMetadata="author" />
// List view with item actions
<UnifiedPageList
pages={pages}
view="list"
renderItemAction={(page) => <Button size="sm">Edit</Button>}
/>Views:
wrapped — PillLink pills in a flex-wrap layout (default)list — One PillLink per line with optional metadata bylineList Metadata options:
none — No byline (default)author — Show author usernamedate — Show created/modified dateearnings — Show page earningsUsed in:
PageItem shape:
id, title (required)isPublic, userId, username, lastModified, createdAt, earnings (optional)app/components/ui/StatsCard.tsx
Unified cards for displaying page statistics with sparklines, animated values, and consistent styling
All StatsCards share this consistent layout: icon + title on left, sparkline + value pill on right.
Cards maintain min-h-[52px] during loading to prevent layout shift.
Editable cards show a dashed border placeholder when empty. Non-editable empty cards can be hidden with hideWhenEmpty.
Cards with onClick get hover styles and cursor pointer.
Children appear below a separator line. Used for diff previews in Recent Edits.
2-column (default for Views + Edits):
3-column (with Supporters):
| Prop | Type | Default | Description |
|---|---|---|---|
icon | IconName | required | Icon to display in header |
title | string | required | Title text for the header |
value | number | string | null | - | Main value (numbers animate) |
sparklineData | number[] | - | 24h trend data (24 points) |
showSparkline | boolean | true | Toggle sparkline visibility |
loading | boolean | false | Show loading spinner |
onClick | function | - | Click handler (enables hover) |
isEditable | boolean | false | Shows dashed placeholder when empty |
hideWhenEmpty | boolean | false | Hide card when value is empty/0 |
children | ReactNode | - | Content below separator (diff preview) |
hideWhenEmpty for non-essential statsisEditable with onClick for user-editable fieldsisEditable stylingapp/components/ui/pie-chart.tsx
Interactive donut chart with legend. Supports hover/tap interactions, multiple segments, and customizable colors. Mobile-optimized with tap interactions.
Hover over segments or legend items to highlight. Used in /settings/spend page.
Adjust values with sliders to see the chart update dynamically.
Warning (Nearly Full - 92%)
Error (Overspent - 120%)
Shows progress toward a goal with a background track. Set showTrack=true and maxValue to enable.
25% Progress
65% Progress
Multi-segment Progress (45%)
Small (80px)
Medium (120px)
Large (160px)
segments: PieChartSegment[]- Array of segments with id, value, label, color classessize?: number- Chart diameter in pixels (default: 120)strokeWidth?: number- Thickness of the donut ring (default: 16)showPercentage?: boolean- Show percentage in center (default: true)centerLabel?: string- Label below percentage (default: 'allocated')formatValue?: (value: number) => string- Format function for legend valuesshowTotal?: boolean- Show total row in legend (default: false)totalLabel?: string- Label for total row (default: 'Total')showTrack?: boolean- Show background track for partial fill (default: false)trackColor?: string- Background track color (default: 'rgba(0,0,0,0.08)')maxValue?: number- Max value for partial fill mode (enables gauge-style display)interface PieChartSegment {
id: string; // Unique identifier
value: number; // Numeric value
label: string; // Display label
color: string; // Tailwind stroke class (e.g., 'stroke-primary')
bgColor: string; // Tailwind bg class for legend dot
textColor?: string; // Optional text color for value
}app/components/utils/PillLink.tsx + UsernameBadge.tsx
Complete showcase of all pill link styles and types. Hover and click to test interactions!
Table Structure: Rows = Styles, Columns = Link Types. All interactions use scale-[1.05] on hover and scale-[0.95] on active.
| Style | Page Link | User (no sub) | User (tier 3) | External Link | Compound Link |
|---|---|---|---|---|---|
Filled Default style | AI Research | alex | sarah | Documentation | |
Outline Bordered style | AI Research | alex | sarah | Documentation | |
Text Only Minimal style | AI Research | alex | sarah | Documentation | |
Underlined Always underlined | AI Research | alex | sarah | Documentation |
Users can change their preferred pill style in Settings via PillStyleContext.
filled - Bold filled background (default)outline - Bordered with transparent backgroundtext_only - Clean text that underlines on hoverunderlined - Always underlined textWhen a page author doesn't have an active subscription, their external links are rendered as plain text with a dotted underline. The link data is preserved in the document, but clicking shows a toast explaining the limitation.Hover to see the tooltip, click to see the toast message!
Implementation Note: The disabled prop for external links renders the link as plain text with decoration-dotted underline and cursor-not-allowed. Uses SimpleTooltip (secondary variant) on hover, and clicking/tapping shows a toast explaining external links require a subscription.
app/hooks/usePillClickAnimation.ts
Pulse Glow is locked in as the default click animation. Below are reveal/expand variants that animate the pill into a page transition.
Click each pill to see how it animates the text away and transitions into the page. These combine Pulse Glow with expand/morph effects.
Glow ring + slight scale. No expand — navigation happens instantly. This is the locked-in default.
Glow ring pulses, text blurs and fades out, pill disappears — page loads underneath.
Pill expands into a full content page with back, logo, share, title, byline & skeleton. Back button reverses the animation.
Glow, pill stretches into a full-width bar, then expands vertically and transitions to background color.
Glow ring, text zooms toward the viewer and blurs away — feels like diving into the link.
.pill-anim-pulse-glowSubtle scale with an accent-colored ring that fades out.
.pill-anim-popQuick scale up then down — snappy and satisfying.
.pill-anim-squishHorizontal squeeze with vertical stretch — playful & organic.
.pill-anim-jellyWobbly elastic bounce — fun and bouncy like jello.
.pill-anim-ripple-outMaterial-inspired ring that expands outward on click.
.pill-anim-bounce-dropDrops down slightly and bounces back — tactile gravity feel.
.pill-anim-magnetic-snapPulls inward with a brightness flash, then snaps back out sharply.
.pill-anim-confetti-burstPop with expanding double-ring — celebratory and energetic.
Pulse Glow is now wired into PillLink.tsx for all internal link clicks in view mode. The animation plays, then navigation fires mid-animation.
Reveal variants use the usePillClickAnimation hook:
import { usePillClickAnimation } from '../../hooks/usePillClickAnimation';
const { animateAndNavigate } = usePillClickAnimation();
// On click:
animateAndNavigate(
pillElement, // The <a> DOM node
() => router.push(url), // Navigation callback
'glow-expand' // Animation variant
);Available variants:
pulse-glow — Default. Glow ring only.glow-fade — Text blurs out, pill fades.glow-expand — Pill expands to fill viewport.glow-morph — Pill morphs to full-width bar, then fills viewport.glow-zoom — Text zooms toward viewer and fades.app/components/ui/progress.tsx
Linear progress indicator built on Radix UI. Used for loading states, upload progress, quota usage, and other measurable values.
app/components/ui/rolling-counter.tsx
Animated counter with slot machine style rolling digits. Also known as 'odometer' in other design systems. Features direction-aware animation (rolls up when increasing, down when decreasing) and adaptive speed for rapid changes. Perfect for view counts, stats, and financial displays.
Views Counter
Dollar Amount
With commas (default)
1,234,567Without commas
1234567With prefix
$1,234.56With suffix
42 itemsComposes Badge with RollingCounter for animated pill counters. Inherits all Badge variants and shiny mode support.
Variants (click to increment)
Sizes
With Prefix/Suffix
Static (no animation)
Smooth animated transitions between save states: unsaved → saving → saved. Uses framer-motion for crossfade animations.
import AutoSaveIndicator from '@/components/layout/AutoSaveIndicator';
<AutoSaveIndicator
status="pending" // 'idle' | 'pending' | 'saving' | 'saved' | 'error'
lastSavedAt={new Date()}
error="Optional error message"
/>app/components/search/SearchResultsDisplay.tsx
Search result list items with keyboard navigation support. Users can press ↑↓ to navigate and Enter to open.
Click the buttons below to simulate keyboard navigation, or use the actual ↑↓ keys while focused.
Currently selected: Index 2
Unselected (default)
Selected (keyboard focus)
Searching for "example"...
No results found for "xyz123"
Use ↑ ↓ to navigate, Enter to open
Selected items use bg-black/5 dark:bg-white/5 with a subtleoutline-black/10 dark:outline-white/10 border. Using Tailwind's built-in black/white with opacity ensures reliable rendering across all themes.
Results are displayed in sections: Users first, then Pages. Keyboard navigation follows this order from top to bottom.
app/components/ui/segmented-control.tsx
iOS-style segmented control with animated sliding background. Active segment slides smoothly using spring animation. Integrates with shiny style system when enabled.
Showing daily view data
app/components/ui/select.tsx
Custom dropdown select component for choosing from a list of options. Supports controlled and uncontrolled usage with animated item transitions.
app/globals.css
Two shadow modes: Normal (neutral) for general use, and Styled (color-matched) for colored buttons where the shadow should match the button color.
shadow-smshadow-mdshadow-lgNeutral black/gray shadows. Use for cards, surfaces, dropdowns.
Shadow color matches the button color. Always visible (no shiny mode required).
Icon buttons — styled shadow on colored, normal on neutral.
shadow-sm, shadow-md, etc.)For cards, surfaces, dropdowns, and neutral/secondary buttons. Shadow is always black/gray.
.shadow-styled-primary, .shadow-styled-success, .shadow-styled-error)For colored buttons where the shadow should reinforce the button's color. A green button gets a green shadow, a red button gets a red shadow.
.shadow-styled-primary — blue shadow (for default/primary buttons)
.shadow-styled-success — green shadow (for success buttons)
.shadow-styled-error — red shadow (for destructive buttons)
Each styled shadow class includes :hover (larger glow) and :active (pressed inset) states automatically.
The Shiny Button System also provides color-matched shadows, but only when shiny mode is enabled by the user. Styled shadows are always visible regardless of mode, making them the right choice when a colored shadow is part of the core design rather than a decorative enhancement.
app/components/ui/slider.tsx
Range slider built on Radix UI for selecting numeric values. Supports single value and range selection with customizable min/max/step.
app/globals.css, app/styles/card-theme.css
Layered background system for pages, cards, drawers, and overlays
bg-background
Page body, sidebars
--backgroundbg-card
Drawers, dialogs, solid cards
--cardbg-popover
Dropdowns, tooltips, popovers
--popoverbg-muted
Inset surfaces, input backgrounds
--muted.wewrite-card
Semi-transparent with backdrop blur
--card-bgbg-background — Page body behind everything. Only use for the root page container.
bg-card — Elevated opaque surfaces: drawers, modals, dialog content, sheet panels. Differentiates from page background in dark mode.
.wewrite-card — Content cards within a page. Glassmorphic (semi-transparent + blur). Use for inline cards, not full-screen containers.
bg-popover — Small floating UI: dropdown menus, tooltips, combobox lists.
bg-muted — Recessed/inset areas: section backgrounds, input fields, chip groups.
bg-background on drawers: In dark mode, drawers blend into the black page background. Use bg-card instead for visible elevation.
wewrite-card on full-screen overlays: The glassmorphic blur is designed for inline cards, not full-screen containers. Use bg-card for drawers and dialogs.
bg-muted for cards: Muted is for recessed areas (lower than background), not elevated surfaces. Use bg-card or .wewrite-card for elevated content.
app/components/ui/table.tsx
Data table component with proper border styling using border-theme-strong class
| Name | Status | Amount |
|---|---|---|
| Alice Johnson | Active | $250.00 |
| Bob Smith | Pending | $150.00 |
| Carol White | Inactive | $350.00 |
| Total | $750.00 | |
border-theme-strong- For header/footer rows (60% opacity)border-b border-border- For body rows (uses --border variable)app/components/ui/tabs.tsx
Simple tabs for switching between content sections. Underline style indicates active tab.
Overview content goes here. This is the first tab.
Manage users and permissions.
app/components/ui/textarea.tsx
Multi-line glassmorphic text input with resize capabilities
This field is required
This might cause issues
@/components/ui/use-toast (powered by Sonner)
Toast notifications for transient feedback. Toasts appear at the bottom-right and auto-dismiss. Use for confirmations, errors, and status updates.
Error toasts can include a copy button for debugging info.
Shows loading state, then success/error based on promise result.
Import
import { toast } from '@/components/ui/use-toast';Basic Usage
// Helper methods (recommended)
toast.success("Success message");
toast.error("Error message");
toast.info("Info message");
toast.warning("Warning message");
toast.loading("Loading...");
// With description
toast.success("Saved", { description: "Your changes have been saved." });
// Error with copy button (for debugging)
toast.error("Payment failed", {
description: "Card declined",
enableCopy: true,
copyText: "Full error details for debugging"
});
// With action button
toast("File deleted", {
action: {
label: "Undo",
onClick: () => console.log("Undo clicked")
}
});
// Promise-based (loading -> success/error)
toast.promise(fetchData(), {
loading: "Loading...",
success: "Data loaded!",
error: "Failed to load"
});Dismiss Programmatically
const toastId = toast.loading("Processing...");
// Later...
toast.dismiss(toastId); // Dismiss specific toast
// Or dismiss all
toast.dismiss();app/components/ui/tooltip.tsx
Accessible tooltips for displaying additional information on hover. Uses Radix UI primitives for proper keyboard and screen reader support. TooltipProvider is already wrapped at the app level.
app/components/auth/ChallengeWrapper.tsx
Cloudflare Turnstile integration for bot detection and spam prevention. Test different challenge levels here.
Visible CAPTCHA widget appears. User must interact to prove they're human.
Not Yet Deployed
The ChallengeWrapper component is built but not yet integrated into sensitive actions like registration, page creation, or link addition.
Where to deploy:
How it works:
app/globals.css
Text styles and hierarchy used throughout the application
Regular body text with normal weight and size.
Small text for captions and secondary information.
Extra small text for fine print and metadata.
Medium weight text for emphasis.
Semibold text for stronger emphasis.
app/components/ui/UsernameBadge.tsx
Displays a user's username with subscription tier badge. Only requires userId, username, and tier - the tier is pre-computed by APIs. Supports link and pill variants.
Simplified Props: Only tier is needed now. APIs pre-compute the effective tier.
Auto-fetch: If tier is not provided, the component fetches it from /api/users/full-profile.
Usage Examples
// Minimal usage (tier auto-fetched) <UsernameBadge userId="abc" username="jamie" /> // With pre-fetched tier <UsernameBadge userId="abc" username="jamie" tier="tier2" />
app/components/ui/warning-dot.tsx
Animated indicator dot for signaling warnings, errors, or critical states. Positioned relative to parent elements for badges, icons, and menu items.