@stevederico/skateboard-ui
v2.9.8
Published
React component library for rapid application development. Built with TailwindCSS v4 and shadcn/ui.
Readme
skateboard-ui
React component library for rapid application development. Built with TailwindCSS v4 and shadcn/ui.
Installation
# npm
npm install @stevederico/skateboard-ui
# deno
deno install npm:@stevederico/skateboard-uiQuick Start
import './assets/styles.css';
import { createSkateboardApp } from '@stevederico/skateboard-ui/App';
import constants from './constants.json';
import HomeView from './components/HomeView.jsx';
const appRoutes = [
{ path: 'home', element: <HomeView /> }
];
createSkateboardApp({ constants, appRoutes });That's it! You get routing, auth, layout, landing page, settings, and payments.
Dark Mode Setup
To prevent flash of unstyled content (FOUC) when using dark mode, add this script to your index.html before your app loads:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Prevent dark mode FOUC -->
<script>
try {
const theme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && systemDark)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
</script>
<title>Your App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>This ensures the correct theme is applied before React renders, eliminating any flash between light and dark modes.
Configuration
skateboard-ui requires a constants object that configures your application:
// constants.json or constants.js
const constants = {
// Required: Backend URLs (include /api prefix)
devBackendURL: "http://localhost:8000/api",
backendURL: "https://api.myapp.com/api",
// Required: App identity
appName: "MyApp",
appIcon: "sparkles", // Lucide icon name
// Required: Landing page content
tagline: "Build apps faster with skateboard-ui",
cta: "Get Started",
// Required: Features section
features: {
title: "Everything you need",
items: [
{ icon: "Zap", title: "Fast", description: "Built for speed" },
{ icon: "Shield", title: "Secure", description: "Authentication included" }
]
},
// Required: Company information
companyName: "Your Company",
companyWebsite: "https://yourcompany.com",
companyEmail: "[email protected]",
// Optional: Navigation pages (sidebar + tabbar)
pages: [
{ title: "Home", icon: "home", url: "home" },
{ title: "Search", icon: "search", url: "search" }
],
// Optional: Authentication
noLogin: false, // Set true to disable authentication entirely
authOverlay: false, // Set true to allow unauthenticated /app access (use with useAuthGate)
// Optional: UI visibility
hideSidebar: false,
hideTabBar: false,
hideSidebarHeader: false,
// Optional: Payments (Stripe)
stripeProducts: [
{
name: "Pro Plan",
priceId: "price_123",
price: "$10/month",
lookup_key: "pro_plan",
title: "Go Pro",
interval: "month",
features: ["Unlimited usage", "Priority support", "Advanced features"]
}
],
// Optional: Landing page customization
navLinks: [ // Override header nav links
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ label: "Blog", href: "/blog" }
],
pricing: {
title: "Simple Pricing", // Pricing section heading
extras: ["Priority Customer Support", "Cancel anytime"] // Extra bullets after product features
},
ctaHeading: "Ready To Build?", // CTA section heading
footerLinks: [ // Override footer links
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
{ label: "EULA", href: "/eula" }
],
copyrightText: "All rights reserved.", // Copyright suffix after "© {year} {companyName}."
// Optional: Legal documents (plain text, supports _COMPANY_, _WEBSITE_, _EMAIL_ placeholders)
termsOfService: "Terms of Service for _COMPANY_...",
privacyPolicy: "Privacy Policy for _COMPANY_...",
EULA: "End User License Agreement...",
subscriptionDetails: "Subscription details...",
// Optional: App metadata
version: "1.0.0"
}Backend URL Pattern
The devBackendURL and backendURL should include your full API base path (including the /api prefix):
const constants = {
devBackendURL: "http://localhost:8000/api", // Include /api prefix
backendURL: "https://api.myapp.com/api",
}Endpoints are relative to this base URL:
${getBackendURL()}/signup→http://localhost:8000/api/signup${getBackendURL()}/me→http://localhost:8000/api/me${getBackendURL()}/deals→http://localhost:8000/api/deals
Tip: Include API versioning in the base URL (e.g., /api/v2) rather than in each endpoint path.
createSkateboardApp
The bootstrap function that sets up routing, auth, theming, state, toasts, and error handling.
import { createSkateboardApp } from '@stevederico/skateboard-ui/App';
createSkateboardApp({
constants, // Required: App configuration object
appRoutes, // Required: [{ path: string, element: JSX.Element }]
defaultRoute, // Optional: Default route path (defaults to first appRoute path)
landingPage, // Optional: Custom landing page JSX element
wrapper, // Optional: React component to wrap the router (e.g., for providers)
});What It Sets Up
- Routes: Landing, signin, signup, signout, app routes, settings, payment, legal pages (terms, privacy, EULA, subscription)
- Authentication: ProtectedRoute wrapping
/app/*, AuthOverlay for lazy auth - Theming: next-themes ThemeProvider with system theme support
- State: ContextProvider with user, UI, and auth overlay state
- Toasts: Sonner Toaster (top-right, rich colors, close button)
- Error Boundary: Catches render errors, unhandled rejections, and global errors
Generated Routes
| Route | Component | Protected |
|-------|-----------|-----------|
| / | LandingView (or custom landingPage) | No |
| /signin | SignInView | No |
| /signup | SignUpView | No |
| /signout | SignOutView | No |
| /app/:path | Your appRoutes | Yes |
| /app/settings | SettingsView | Yes |
| /app/payment | PaymentView | Yes |
| /terms | TextView | No |
| /privacy | TextView | No |
| /eula | TextView | No |
| /subscription | TextView | No |
| * | NotFound | No |
Authentication
Overview
skateboard-ui uses a hybrid cookie + localStorage authentication system that combines security with performance:
Frontend Backend Storage
──────── ─────── ───────
1. POST /signin → Validate credentials
credentials
← Set-Cookie: {appName}_token (HttpOnly)
← Set-Cookie: csrf_token
← Response: { csrfToken, ...user }
2. Extract tokens → localStorage:
- CSRF from cookie {appName}_csrf
- User from response {appName}_user
3. isAuthenticated() → Check localStorage
(client-side) (fast, no network)
4. ProtectedRoute → GET /me
(server validation) Validate cookies
← 200 OK or 401 Unauthorized
5. API requests → Protected endpoints
+ cookies (automatic) Validate {appName}_token
+ X-CSRF-Token header Validate CSRF headerCookie-Based Session Management
- Session token stored in
{appName}_tokencookie (HttpOnly, Secure, SameSite=Strict) - Automatically sent with every request via browser
- Cannot be accessed by JavaScript (XSS protection)
- Backend validates cookie on each protected endpoint
localStorage for Client-Side Validation
- CSRF token and user data stored in localStorage
- Enables instant
isAuthenticated()checks without network calls - Used by client-side routing logic (ProtectedRoute initial check)
- Not used for actual authentication (cookies handle that)
CSRF Protection
- Dual-token system prevents CSRF attacks
- CSRF token sent in
X-CSRF-Tokenheader with state-changing requests - Backend validates header matches stored session CSRF token
- Separate from session cookie to prevent cookie-based CSRF
CSRF Error Handling
The apiRequest utility automatically handles CSRF token failures:
- Auto-Regeneration: Backend auto-regenerates tokens after server restart
- Retry Logic: Frontend automatically retries failed requests once after refreshing the session
- User Experience: Transparent recovery without forcing sign-out or page refresh
Error Flow:
POST /api/keys → 403 CSRF error
↓
Fetch /me (triggers backend auto-regeneration)
↓
Retry POST /api/keys with fresh token
↓
SuccessRequired Backend Endpoints
POST /signup
Create new user account.
Request:
{
"email": "[email protected]",
"password": "securePassword123",
"name": "John Doe"
}Response:
- Status: 201 Created
- Headers:
Set-Cookie: {appName}_token={sessionToken}; HttpOnly; Secure; SameSite=Strict; Path=/Set-Cookie: csrf_token={csrfToken}; Secure; SameSite=Lax; Path=/
- Body:
{
"csrfToken": "csrf_abc123...",
"user": {
"id": "user123",
"email": "[email protected]",
"name": "John Doe"
}
}POST /signin
Authenticate existing user.
Request:
{
"email": "[email protected]",
"password": "securePassword123"
}Response:
- Status: 200 OK
- Headers: Same as /signup
- Body: Same as /signup
GET /me
Validate current session and return user data.
Request:
- Headers: Cookies automatically sent by browser
Response (authenticated):
- Status: 200 OK
- Body:
{
"user": {
"id": "user123",
"email": "[email protected]",
"name": "John Doe",
"subscription": {
"status": "active",
"expires": 1735689600,
"stripeID": "cus_abc123"
}
}
}Response (not authenticated):
- Status: 401 Unauthorized
POST /signout
End current session.
Request:
- Headers:
- Cookies automatically sent
X-CSRF-Token: {csrfToken}
Response:
- Status: 200 OK
- Headers:
Set-Cookie: {appName}_token=; Max-Age=0; Path=/(clear cookie)Set-Cookie: csrf_token=; Max-Age=0; Path=/(clear cookie)
Optional Backend Endpoints
POST /usage
Track and check feature usage limits.
Request:
{ "operation": "check" }or
{ "operation": "track" }Response:
{ "remaining": 15, "total": 20, "isSubscriber": false }GET /isSubscriber
Check subscription status.
Response:
{ "isSubscriber": true }POST /checkout (Stripe)
Create a Stripe checkout session.
Request:
{ "lookup_key": "pro_plan", "email": "[email protected]" }Response:
{ "url": "https://checkout.stripe.com/..." }POST /portal (Stripe)
Open Stripe billing portal.
Request:
{ "customerID": "cus_abc123" }Response:
{ "url": "https://billing.stripe.com/..." }Cookie Configuration
Session Token Cookie:
{
name: '{appName}_token',
httpOnly: true, // Prevents JavaScript access (XSS protection)
secure: true, // HTTPS only (production)
sameSite: 'Strict', // Strongest CSRF protection
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days (configurable)
}CSRF Token Cookie:
{
name: 'csrf_token',
httpOnly: false, // Must be readable by JavaScript
secure: true, // HTTPS only (production)
sameSite: 'Lax', // Allow top-level navigation
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000 // Match session token
}Protected Endpoints
All authenticated endpoints must:
- Validate
{appName}_tokencookie exists and is valid - For state-changing operations (POST, PUT, DELETE), validate
X-CSRF-Tokenheader - Return 401 if authentication fails
- Return 403 if CSRF validation fails
Security Considerations
- XSS Protection: Session token is HttpOnly — JavaScript cannot access it
- CSRF Protection: Dual-token pattern prevents cookie-based CSRF attacks
- SameSite Policy: Session token (Strict), CSRF token (Lax)
- HTTPS Requirement: All cookies marked
Securein production - localStorage Trade-offs: Acceptable for CSRF token (cannot authenticate alone), never store session token
Example Backend Implementation (Express.js)
import express from 'express';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';
const app = express();
app.use(express.json());
app.use(cookieParser());
// In-memory session store (use Redis in production)
const sessions = new Map();
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
function requireAuth(req, res, next) {
const sessionToken = req.cookies.myapp_token;
const session = sessions.get(sessionToken);
if (!session) return res.status(401).json({ error: 'Not authenticated' });
req.session = session;
next();
}
function requireCSRF(req, res, next) {
const csrfToken = req.headers['x-csrf-token'];
if (!req.session || req.session.csrfToken !== csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
app.post('/api/signup', async (req, res) => {
const { email, password, name } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const user = await createUser(email, password, name);
const sessionToken = generateToken();
const csrfToken = generateToken();
sessions.set(sessionToken, { userId: user.id, csrfToken, createdAt: Date.now() });
res.cookie('myapp_token', sessionToken, {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000
});
res.cookie('csrf_token', csrfToken, {
httpOnly: false, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000
});
res.status(201).json({ csrfToken, user: { id: user.id, email: user.email, name: user.name } });
});
app.get('/api/me', requireAuth, async (req, res) => {
const user = await getUserById(req.session.userId);
res.json({ user: { id: user.id, email: user.email, name: user.name } });
});
app.post('/api/signout', requireAuth, requireCSRF, (req, res) => {
sessions.delete(req.cookies.myapp_token);
res.clearCookie('myapp_token');
res.clearCookie('csrf_token');
res.json({ message: 'Signed out successfully' });
});Auth Troubleshooting
| Problem | Cause | Fix |
|---------|-------|-----|
| "Not authenticated" after signin | Cookies not sent | Verify credentials: 'include' in fetch calls |
| CSRF 403 errors | Token mismatch | Check X-CSRF-Token header, verify cookie exists |
| Cookies not persisting | SameSite/Secure flags | Set secure: false in dev, check domain match |
| isAuthenticated() false but cookie exists | localStorage cleared | Re-fetch from /me endpoint |
No-Login Mode
const constants = { noLogin: true };Effects: isAuthenticated() always returns true, ProtectedRoute allows all access.
Components
Core Components
| Component | Import | Description |
|-----------|--------|-------------|
| Sidebar | @stevederico/skateboard-ui/Sidebar | Desktop navigation sidebar with collapsible icon mode |
| Header | @stevederico/skateboard-ui/Header | App header with title and action button |
| Layout | @stevederico/skateboard-ui/Layout | Page layout with sidebar (desktop) and tabbar (mobile) |
| TabBar | @stevederico/skateboard-ui/TabBar | Mobile bottom navigation with labels |
| DynamicIcon | @stevederico/skateboard-ui/DynamicIcon | Lucide icon by name string |
| ThemeToggle | @stevederico/skateboard-ui/ThemeToggle | Dark/light mode toggle button |
| Sheet | @stevederico/skateboard-ui/Sheet | Slide-out panel |
| UpgradeSheet | @stevederico/skateboard-ui/UpgradeSheet | Premium upgrade drawer |
| ErrorBoundary | @stevederico/skateboard-ui/ErrorBoundary | Error boundary wrapper |
View Components
| Component | Import | Description |
|-----------|--------|-------------|
| LandingView | @stevederico/skateboard-ui/LandingView | Landing page with hero, features, pricing |
| LandingViewSimple | @stevederico/skateboard-ui/LandingViewSimple | Minimal landing page |
| SignInView | @stevederico/skateboard-ui/SignInView | Sign in form with Card layout |
| SignUpView | @stevederico/skateboard-ui/SignUpView | Sign up form with password validation |
| SignOutView | @stevederico/skateboard-ui/SignOutView | Sign out handler with redirect |
| SettingsView | @stevederico/skateboard-ui/SettingsView | User settings, billing, theme |
| PaymentView | @stevederico/skateboard-ui/PaymentView | Stripe payment redirect handler |
| TextView | @stevederico/skateboard-ui/TextView | Legal document viewer with placeholder replacement |
| NotFound | @stevederico/skateboard-ui/NotFound | 404 page |
Auth Components
| Export | Import | Description |
|--------|--------|-------------|
| AuthOverlay | @stevederico/skateboard-ui/AuthOverlay | Modal sign-in/sign-up dialog |
| useAuthGate | @stevederico/skateboard-ui/useAuthGate | Hook to gate actions behind auth |
State & Utilities
| Export | Import | Description |
|--------|--------|-------------|
| Context | @stevederico/skateboard-ui/Context | App state provider and accessor |
| Utilities | @stevederico/skateboard-ui/Utilities | API, auth, formatting, and UI utilities |
| App | @stevederico/skateboard-ui/App | createSkateboardApp bootstrap function |
| ProtectedRoute | @stevederico/skateboard-ui/ProtectedRoute | Route guard with server validation |
Component Details
Sidebar
Desktop navigation sidebar with collapsible icon mode, user dropdown, and settings link.
import Sidebar from '@stevederico/skateboard-ui/Sidebar';
// Used internally by Layout. Renders automatically based on constants.Reads from constants:
pages— Navigation items rendered as sidebar menu buttonsappName— Displayed in sidebar headerappIcon— Icon in sidebar headerhideSidebarHeader— Hides the header whentrue
Features:
- Collapsible to icon-only mode via rail
- Active page highlighting based on current route
- Tooltip labels when collapsed
- Footer with Settings button and user dropdown (account, billing, notifications, sign out)
Header
import Header from '@stevederico/skateboard-ui/Header';
<Header
title="Dashboard"
buttonTitle="Add"
onButtonTitleClick={() => console.log('clicked')}
buttonClass="bg-app text-white"
className="sticky top-0"
>
{/* Optional: custom right-side content */}
</Header>Props:
| Prop | Type | Default | Description | |------|------|---------|-------------| | title | string | required | Header title | | buttonTitle | string | — | Action button text (omit to hide) | | onButtonTitleClick | function | — | Button click handler | | buttonClass | string | — | Additional button CSS classes | | className | string | — | Additional header CSS classes | | children | ReactNode | — | Custom right-side content |
DynamicIcon
Renders a Lucide icon by name string. Accepts kebab-case, snake_case, or PascalCase.
import DynamicIcon from '@stevederico/skateboard-ui/DynamicIcon';
<DynamicIcon name="home" size={24} />
<DynamicIcon name="arrow-right" size={20} color="red" />
<DynamicIcon name="settings" className="text-muted-foreground" />Props:
| Prop | Type | Default | Description | |------|------|---------|-------------| | name | string | required | Icon name (e.g. "home", "arrow-right", "Settings") | | size | number | 24 | Icon size in pixels | | color | string | 'currentColor' | Stroke color | | strokeWidth | number | 2 | Stroke width | | className | string | — | Additional CSS classes |
Icons from lucide-react. Returns null if icon name not found.
ThemeToggle
import ThemeToggle from '@stevederico/skateboard-ui/ThemeToggle';
<ThemeToggle />
<ThemeToggle variant="landing" iconSize={18} />Props:
| Prop | Type | Default | Description | |------|------|---------|-------------| | className | string | "" | Additional CSS classes | | iconSize | number | 16 | Icon size in pixels | | variant | string | "settings" | "settings" (ghost) or "landing" (outline) |
TabBar
Mobile bottom navigation bar. Hidden on md+ screens. Renders pages from constants.pages plus a Settings link.
import TabBar from '@stevederico/skateboard-ui/TabBar';
// Used internally by Layout. Renders automatically.Features:
- Fixed bottom position on mobile
- Active page highlighting with bold stroke
- Text labels under each icon
- Settings link appended automatically
UpgradeSheet
Drawer component for premium upgrade prompts. Controlled via ref.
import { useRef } from 'react';
import UpgradeSheet from '@stevederico/skateboard-ui/UpgradeSheet';
import { showUpgradeSheet } from '@stevederico/skateboard-ui/Utilities';
function MyComponent() {
const upgradeRef = useRef();
return (
<>
<button onClick={() => showUpgradeSheet(upgradeRef)}>
Upgrade
</button>
<UpgradeSheet ref={upgradeRef} userEmail={user.email} />
</>
);
}Ref Methods: show(), open(), hide(), close(), toggle()
Props:
| Prop | Type | Description | |------|------|-------------| | userEmail | string | User's email for Stripe checkout |
Reads from constants: stripeProducts[0] (title, price, features)
TextView
Renders legal documents with placeholder replacement.
import TextView from '@stevederico/skateboard-ui/TextView';
<TextView details={constants.termsOfService} />Props:
| Prop | Type | Description | |------|------|-------------| | details | string | Text content with optional placeholders | | className | string | Additional CSS classes |
Placeholders: _COMPANY_ → companyName, _WEBSITE_ → companyWebsite, _EMAIL_ → companyEmail
ErrorBoundary
Catches render errors, unhandled promise rejections, and global errors. Shows an error card with retry options.
import ErrorBoundary from '@stevederico/skateboard-ui/ErrorBoundary';
<ErrorBoundary>
<App />
</ErrorBoundary>Context (State Management)
import { getState, useUser, useDispatch } from '@stevederico/skateboard-ui/Context';
function MyComponent() {
const { state, dispatch } = getState();
// Access state
const user = state.user;
const constants = state.constants;
// Dispatch actions
dispatch({ type: 'SET_USER', payload: userData });
dispatch({ type: 'CLEAR_USER' });
}Optimized Hooks
Use these hooks to avoid unnecessary re-renders:
import { useUser, useDispatch } from '@stevederico/skateboard-ui/Context';
// Only re-renders when user changes (not on sidebar/theme changes)
function ProfileCard() {
const user = useUser();
if (!user) return null;
return <div>{user.name}</div>;
}
// Stable dispatch reference, never causes re-renders
function SignOutButton() {
const dispatch = useDispatch();
return <button onClick={() => dispatch({ type: 'CLEAR_USER' })}>Sign Out</button>;
}| Hook | Returns | Re-renders on |
|------|---------|---------------|
| getState() | { state, dispatch } | Any state change |
| useUser() | user or null | User changes only |
| useDispatch() | dispatch | Never (stable) |
State Shape
{
user: {
id: string,
email: string,
name: string,
subscription: {
status: 'active' | 'canceled' | null,
expires: number, // Unix timestamp (seconds)
stripeID: string
}
} | null,
ui: {
sidebarVisible: boolean,
tabBarVisible: boolean
},
authOverlay: {
visible: boolean,
pendingCallback: Function | null
},
constants: Object // App configuration
}Available Actions
| Action | Payload | Description |
|--------|---------|-------------|
| SET_USER | user object | Set authenticated user |
| CLEAR_USER | — | Clear user (logout) |
| SET_SIDEBAR_VISIBLE | boolean | Show/hide sidebar |
| SET_TABBAR_VISIBLE | boolean | Show/hide tab bar |
| SET_UI_VISIBILITY | { sidebar?, tabBar? } | Batch update UI visibility |
| SHOW_AUTH_OVERLAY | callback or null | Show auth dialog, optionally queue callback |
| HIDE_AUTH_OVERLAY | — | Hide auth dialog |
| AUTH_OVERLAY_SUCCESS | — | Auth success, run pending callback and close |
localStorage Keys
All keys are namespaced with {appName}_:
| Key | Description |
|-----|-------------|
| {appName}_user | Persisted user object |
| {appName}_csrf | CSRF token (fallback, primary is cookie) |
| {appName}_beforeCheckoutURL | Redirect URL after Stripe checkout |
| {appName}_beforeManageURL | Redirect URL after Stripe portal |
Utilities
import {
apiRequest,
apiRequestWithParams,
isAuthenticated,
getCurrentUser,
isSubscriber,
getCSRFToken,
getBackendURL,
getAppKey,
getConstants,
getRemainingUsage,
trackUsage,
showCheckout,
showManage,
showUpgradeSheet,
showSidebar,
hideSidebar,
showTabBar,
hideTabBar,
setSidebarVisible,
setTabBarVisible,
setUIVisibility,
timestampToString,
useListData,
useForm,
useAppSetup,
isAppMode,
validateConstants,
} from '@stevederico/skateboard-ui/Utilities';API Requests
// GET
const data = await apiRequest('/deals');
// POST
const newDeal = await apiRequest('/deals', {
method: 'POST',
body: JSON.stringify({ name: 'New Deal', amount: 5000 })
});
// GET with query params
const filtered = await apiRequestWithParams('/deals', { status: 'active', limit: 10 });Features:
- Auto-includes credentials (cookies)
- Auto-adds
X-CSRF-Tokenheader for POST, PUT, DELETE, PATCH - Auto-redirects to
/signouton 401 (unless authOverlay mode) - Auto-retries once on CSRF 403 failure
Auth Utilities
// Client-side check (fast, no network)
if (isAuthenticated()) {
const user = getCurrentUser();
}
// Server-side validation
const user = await getCurrentUser(); // Calls GET /me
// Check subscription
const subscribed = await isSubscriber(); // Calls GET /isSubscriber
// Get CSRF token (from cookie, falls back to localStorage)
const token = getCSRFToken();
// Get backend URL (devBackendURL in dev, backendURL in production)
const url = getBackendURL();
// Generate app-namespaced localStorage key
const key = getAppKey('user'); // → "{appName}_user"Usage Tracking
// Check remaining usage for an action
const usage = await getRemainingUsage('messages');
// { remaining: 15, total: 20, isSubscriber: false }
// Track usage (decrements remaining)
const updated = await trackUsage('messages');Stripe Payments
// Redirect to Stripe checkout
showCheckout('[email protected]', 0); // productIndex defaults to 0
// Open Stripe billing portal
showManage('cus_abc123');
// Show upgrade sheet if not subscriber
showUpgradeSheet(upgradeSheetRef);Data Fetching Hook
import { useListData } from '@stevederico/skateboard-ui/Utilities';
function DealsList() {
const { data, loading, error, refetch } = useListData('/deals');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return data.map(deal => <DealCard key={deal.id} {...deal} />);
}
// With custom sort
const { data } = useListData('/deals', (a, b) => b.amount - a.amount);Form Hook
import { useForm } from '@stevederico/skateboard-ui/Utilities';
function ContactForm() {
const { values, handleChange, handleSubmit, reset, submitting, error } = useForm(
{ name: '', email: '', message: '' },
async (formValues) => {
await apiRequest('/contact', {
method: 'POST',
body: JSON.stringify(formValues)
});
}
);
return (
<form onSubmit={handleSubmit}>
<input name="name" value={values.name} onChange={handleChange} />
<input name="email" value={values.email} onChange={handleChange} />
<textarea name="message" value={values.message} onChange={handleChange} />
<button type="submit" disabled={submitting}>Send</button>
{error && <p>{error}</p>}
</form>
);
}Timestamp Formatting
import { timestampToString } from '@stevederico/skateboard-ui/Utilities';
timestampToString(1706000000, 'ago'); // "2 hours ago"
timestampToString(1706000000, 'DOB'); // "Jan 23, 2024"
timestampToString(1706000000, 'DOBT'); // "Jan 23, 2024 3:00 PM"
timestampToString(1706000000, 'ISO'); // "2024-01-23"
timestampToString(1706000000, 'day-month-time');// "23 Jan 3:00 PM"
timestampToString(1706000000, 'day'); // "Monday"
timestampToString(1706000000, 'time'); // "3:00 PM"
timestampToString(1706000000, 'full'); // "Monday, Jan 23, 2024 3:00 PM"UI Visibility Control
// Programmatic control
hideSidebar();
showSidebar();
hideTabBar();
showTabBar();
// Set directly
setSidebarVisible(false);
setTabBarVisible(true);
// Batch control
setUIVisibility({ sidebar: false, tabBar: false });Other Utilities
// Check if running inside native WebKit wrapper (iOS/macOS app)
if (isAppMode()) { /* native context */ }
// Validate constants object (called internally by createSkateboardApp)
validateConstants(constants);
// Get constants object
const constants = getConstants();Lazy Authentication (Auth Overlay)
Let users explore /app without signing in — prompt them only when they perform a protected action.
Setup
Set authOverlay: true in your constants to allow unauthenticated access to /app routes:
{
"authOverlay": true
}The AuthOverlay component is rendered automatically by createSkateboardApp — no additional wiring needed.
Usage
import { useAuthGate } from '@stevederico/skateboard-ui/useAuthGate';
function SaveButton() {
const requireAuth = useAuthGate();
function handleSave() {
requireAuth(() => {
// Only runs if user is authenticated
// If not signed in, auth overlay appears first
saveThing();
});
}
return <button onClick={handleSave}>Save</button>;
}How It Works
- User clicks a protected action (Save, Like, Post, etc.)
requireAuth()checks if user is signed in- If signed in — callback runs immediately
- If not — a modal dialog appears with sign-in/sign-up forms
- After successful auth, the original callback executes automatically
- User stays on the same page throughout — no navigation
The dialog supports toggling between sign-in and sign-up modes inline, and can be dismissed with the X button (cancels the action).
Toast Notifications
Toasts are provided by Sonner and rendered automatically by createSkateboardApp.
import { toast } from 'sonner';
toast.success('Changes saved!');
toast.error('Failed to save');
toast.loading('Saving...');
toast.info('New feature available');
toast.warning('This action cannot be undone');
// Promise-based
toast.promise(
fetch('/api/data'),
{
loading: 'Loading...',
success: 'Data loaded!',
error: 'Failed to load'
}
);Styling
Import base theme and override as needed:
/* styles.css */
@import "@stevederico/skateboard-ui/styles.css";
@source '../../node_modules/@stevederico/skateboard-ui';
@theme {
--color-app: var(--color-purple-500);
}Theme Variables
| Variable | Description |
|----------|-------------|
| --color-app | Primary brand color (used for app icon backgrounds, gradient buttons) |
| --background | Page background |
| --foreground | Text color |
| --accent | Secondary backgrounds |
| --radius | Border radius |
Dark mode is automatic via CSS custom properties and next-themes.
shadcn/ui Components
51 components available at @stevederico/skateboard-ui/shadcn/ui/*:
import { Button } from '@stevederico/skateboard-ui/shadcn/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from '@stevederico/skateboard-ui/shadcn/ui/card';
import { Input } from '@stevederico/skateboard-ui/shadcn/ui/input';
import { Label } from '@stevederico/skateboard-ui/shadcn/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@stevederico/skateboard-ui/shadcn/ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@stevederico/skateboard-ui/shadcn/ui/avatar';
import { Badge } from '@stevederico/skateboard-ui/shadcn/ui/badge';
import { Separator } from '@stevederico/skateboard-ui/shadcn/ui/separator';
import { ScrollArea } from '@stevederico/skateboard-ui/shadcn/ui/scroll-area';
import { Skeleton } from '@stevederico/skateboard-ui/shadcn/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@stevederico/skateboard-ui/shadcn/ui/alert';
import { Progress } from '@stevederico/skateboard-ui/shadcn/ui/progress';
import { Switch } from '@stevederico/skateboard-ui/shadcn/ui/switch';
import { Checkbox } from '@stevederico/skateboard-ui/shadcn/ui/checkbox';
import { Textarea } from '@stevederico/skateboard-ui/shadcn/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@stevederico/skateboard-ui/shadcn/ui/tooltip';import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@stevederico/skateboard-ui/shadcn/ui/select';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@stevederico/skateboard-ui/shadcn/ui/dropdown-menu';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader,
AlertDialogTitle, AlertDialogTrigger,
} from '@stevederico/skateboard-ui/shadcn/ui/alert-dialog';
import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from '@stevederico/skateboard-ui/shadcn/ui/accordion';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@stevederico/skateboard-ui/shadcn/ui/table';
import {
Tabs, TabsContent, TabsList, TabsTrigger,
} from '@stevederico/skateboard-ui/shadcn/ui/tabs';Utilities
// Tailwind className merger
import { cn } from '@stevederico/skateboard-ui/shadcn/lib/utils';
cn('px-2 py-1', condition && 'bg-red-500', 'px-4'); // Merges without conflicts
// Mobile detection hook (< 768px)
import { useIsMobile } from '@stevederico/skateboard-ui/shadcn/hooks/use-mobile';
const isMobile = useIsMobile();All components support dark mode automatically and accept a className prop for customization.
Dependencies
Peer Dependencies
- React 19.1+
- react-dom 19.1+
- react-router-dom 7.0+
Core Dependencies
- @base-ui/react — Accessible UI primitives
- lucide-react — Icon library
- next-themes — Theme management
- class-variance-authority — Variant styling
- clsx & tailwind-merge — className utilities
- sonner — Toast notifications
- vaul — Drawer primitives
- cmdk — Command menu
- embla-carousel-react — Carousel
- react-day-picker — Calendar
- react-resizable-panels — Resizable panels
- recharts — Charts
Repository
https://github.com/stevederico/skateboard-ui
