@codesuma/baseline
v1.2.0
Published
A minimal, imperative UI framework for building fast web apps. No virtual DOM, no magic, no dependencies.
Maintainers
Readme
Baseline
A minimal, imperative UI framework for building fast web apps. No virtual DOM, no magic, no dependencies.
~1000 lines of TypeScript that gives you everything you need for SPAs.
Installation
npm install @codesuma/baselineWhy Baseline?
- Predictable - What you write is what happens. No hidden lifecycle, no reactivity magic.
- Fast - Direct DOM manipulation. No diffing algorithms.
- Tiny - The entire framework is smaller than most framework's "hello world" bundle.
- Stable - No dependencies means no breaking changes from upstream.
Examples
Baseline comes with built-in examples to help you get started:
import { CounterExample, TodoExample } from '@codesuma/baseline'
// Use them in your app
document.body.appendChild(CounterExample().el)
document.body.appendChild(TodoExample().el)Quick Start
import { Div, Button, router } from '@codesuma/baseline'
const App = () => {
const app = Div()
const count = { value: 0 }
const counter = Div('0')
counter.style({ fontSize: '48px', textAlign: 'center' })
const btn = Button('Click me')
btn.on('click', () => {
count.value++
counter.text(String(count.value))
})
app.style({ display: 'flex', flexDirection: 'column', gap: '20px', padding: '40px' })
app.append(counter, btn)
return app
}
document.body.appendChild(App().el)Core Concepts
Components
Every component is created with the Base() factory:
import { Base } from '@codesuma/baseline'
const card = Base('div') // Creates a <div>
card.el // The actual DOM element
card.id // Unique identifierOr use pre-made native components:
import { Div, Button, Input, Span, A, Img } from '@codesuma/baseline'
const container = Div('Hello')
const btn = Button('Click')
const input = Input('Enter name...', 'text')
// Support for different input types (First arg is label for check/radio)
const checkbox = Input('Stay signed in', 'checkbox', { checked: true })
const number = Input('Age', 'number', { min: 0, max: 100 })
const date = Input('Date', 'date')
const swatch = Input('', 'color', { value: '#ff8800' })
// Checkbox helpers
checkbox.isChecked() // true
checkbox.toggle()
checkbox.on('change', (checked) => console.log(checked))Reach for the Baseline API first. Whenever Baseline ships an idiomatic equivalent for what you'd otherwise write with raw JS or the raw DOM, use the Baseline one — element creation (
Div/Button/Base('tag')overdocument.createElement), events (.on()overaddEventListener), tree (.append/.empty/.removeoverel.appendChild/innerHTML=''), classes (.addClass/.toggleClassoverel.className=), text/attrs/styles (.text/.attr/.styleoverel.textContent/setAttribute/el.style.x=), storage (ldb/idboverlocalStorage/indexedDB), fetch (httpoverfetch), shared state (stateover ad-hoc globals). Drop to raw JS only at the boundaries Baseline doesn't cover (DOM measurements, observers, Clipboard / File APIs, etc.).// ❌ const btn = document.createElement('button'); btn.addEventListener('click', h) // ✅ const btn = Button('Reset'); btn.on('click', h)
Events
Components have a built-in event emitter that automatically proxies native DOM events. Use .on() for both DOM events (click, focus, input, etc.) and custom events — no need for el.addEventListener:
const btn = Button()
// Native DOM events — automatically proxied to el.addEventListener
btn.on('click', () => console.log('clicked!'))
btn.on('mouseenter', () => console.log('hovered'))
btn.on('focus', () => console.log('focused'))
// Framework lifecycle events
btn.on('mounted', () => console.log('in the DOM'))
// Custom events (component communication via emitter)
btn.emit('save', { id: 1 })
btn.on('save', (data) => console.log(data))
// Listen once
btn.once('click', () => console.log('first click only'))
// Remove listener — removes both emitter and DOM listener
btn.off('click', handler)How it works: When
.on(event, handler)is called, ifeventis a native DOM event (e.g.'click','focus','blur'), the handler is automatically registered on the underlyingelviaaddEventListener. Custom events go through the emitter only..off()cleans up both.
Styling
Two approaches: inline styles or CSS Modules.
Inline Styles
Use for dynamic values and simple components:
// Inline styles
card.style({
opacity: '0.5',
transform: 'translateY(-10px)',
transition: 'all 0.3s ease',
})
// Chained styles
card.style({ opacity: '0' })
.style({ opacity: '1' })CSS Modules (Recommended)
Use .module.css files for scoped, maintainable styles:
card/index.module.css
.base {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 16px;
transition: all 0.3s ease;
}
.base:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-4px);
}
.dark {
background: #1a1a1a;
color: #fff;
}card/index.ts
import { Div } from '@codesuma/baseline'
import styles from './index.module.css'
export const Card = () => {
const base = Div()
base.addClass(styles.base)
// Toggle classes
base.addClass(styles.dark) // Add dark mode
base.removeClass(styles.dark) // Remove dark mode
base.toggleClass(styles.dark, isDark) // Conditional
return base
}Project Structure with CSS Modules
components/
├── card/
│ ├── index.ts # Component logic
│ └── index.module.css # Scoped styles
├── button/
│ ├── index.ts
│ └── index.module.css
pages/
├── home/
│ ├── index.ts
│ └── index.module.cssRollup Configuration
Ensure your rollup.config.js includes postcss:
import postcss from 'rollup-plugin-postcss'
export default {
plugins: [
postcss({
modules: true, // Enable CSS modules
extract: 'bundle.css', // Output file
minimize: true,
}),
],
}Link the CSS in your HTML:
<link rel="stylesheet" href="/bundle.css">DOM Tree
const list = Div()
const item1 = Div('First')
const item2 = Div('Second')
// Append children
list.append(item1, item2)
// Prepend
list.prepend(Div('Zero'))
// Conditional rendering
list.append(
isLoggedIn && UserProfile(),
showFooter ? Footer() : null
)
// Remove
item1.remove()
// Empty all children
list.empty()
// Access children
list.getChildren()Lifecycle
const card = Div()
card.on('mounted', () => {
// Called when component enters the DOM
console.log('Card is visible')
})
card.on('unmounted', () => {
// Called when component is removed
console.log('Card removed')
})Router
Base includes a full-featured SPA router with page lifecycle management. Pages stay in DOM and visibility toggles for smooth transitions.
Basic Setup
import { Div, router } from '@codesuma/baseline'
import { HomePage } from './pages/home'
import { AboutPage } from './pages/about'
import { UserPage } from './pages/user'
const view = Div() // Container for pages
const app = Div()
app.append(view)
// Configure routes - pages stay in DOM, visibility toggles
router.routes({
'/': HomePage,
'/about': AboutPage,
'/users/:id': UserPage,
}, view)
document.body.appendChild(app.el)Route Configuration
router.routes({
// Simple routes
'/': HomePage,
'/about': AboutPage,
// Dynamic parameters
'/users/:id': UserPage,
'/posts/:postId/comments/:commentId': CommentPage,
}, view)How It Works
- All pages are created once and stay in DOM
- Navigation toggles visibility via CSS classes
- Smooth transitions because both pages exist during animation
- Smart Directional Animations: Automatically slides UP for forward navigation and DOWN for back navigation
- State preserved (scroll position, form inputs)
Page Component
Base includes a Page component with built-in CSS transitions for smooth page navigation:
import { Page, IRouteEvent } from '@codesuma/baseline'
export const AboutPage = () => {
const page = Page() // Includes directional enter/exit animations
// Add your content
page.append(header, body)
// Default animations:
// Forward: Enter Up / Exit Up
// Back: Enter Down / Exit Down
return page
}Default animations:
- Forward: Slides up from bottom
- Back: Slides down to bottom
Customizing Page Transitions
import { Page, IRouteEvent } from '@codesuma/baseline'
export const HomePage = () => {
const page = Page()
// Override default enter - show instantly
page.off('enter')
page.on('enter', async ({ from }: IRouteEvent) => {
page.showInstant() // Assuming you implemented this helper or use style overrides
// Load data...
})
return page
}Page methods:
showInstant()- Show page without animationexitDown()- Exit with downward slide (for back navigation)
Page Lifecycle Events
Pages receive enter and exit events from the router:
page.on('enter', async ({ params, query, from, data }: IRouteEvent) => {
// params: { id: '123' } for /users/:id
// query: { sort: 'name' } for ?sort=name
// from: '/home' - previous path
// data: custom data from router.goto()
})
page.on('exit', async () => {
// Called when navigating away
// Async supported - router waits for completion
})Navigation
import { router } from '@codesuma/baseline'
// Navigate to path
router.goto('/about')
// With query params
router.goto('/search?q=hello&page=2')
// With custom data (available in enter event)
router.goto('/users/123', { fromNotification: true })
// Browser history
router.back()
router.forward()
// Get current state
router.getPath() // '/users/123'
router.getParams() // { id: '123' }
router.getQuery() // { sort: 'name' }
router.getQuery('sort') // 'name'
// For heavy pages - manually destroy to force recreation
router.destroyPage('/heavy-page')Resetting Heavy Pages
Since pages stay in DOM, you may want to reset state on exit for heavy pages:
page.on('exit', () => {
// Reset scroll position
page.el.scrollTop = 0
// Clear dynamic content
list.empty()
// Reset form inputs
input.val('')
})
// Or completely destroy the page to force recreation on next visit:
page.on('exit', () => {
router.destroyPage('/heavy-page')
})
### Listening to Route Changes
For global navigation handling:
```typescript
router.on('change', ({ path, params, query, from, data }) => {
// Update navigation UI
updateActiveMenuItem(path)
// Analytics
trackPageView(path)
// Show/hide back button
backButton.style({ display: from ? 'block' : 'none' })
})Complete Example
// app.ts
import { Div, router } from '@codesuma/baseline'
import { FIXED, EASE } from '@codesuma/baseline/helpers/style' // specialized helpers might still need specific paths if not all exported, but index.ts didn't show helpers/style exported. Let's check index.ts again.
import { HomePage } from './pages/home'
import { AboutPage } from './pages/about'
import { UserPage } from './pages/user'
const view = Div()
const app = Div()
const nav = Div()
// Navigation
const homeLink = Div('Home')
homeLink.on('click', () => router.goto('/'))
const aboutLink = Div('About')
aboutLink.on('click', () => router.goto('/about'))
nav.style({ display: 'flex', gap: '20px', padding: '10px' })
nav.append(homeLink, aboutLink)
app.style({ ...FIXED })
app.append(nav, view)
// Configure routes
router.routes({
'/': { page: HomePage, cache: true },
'/about': { page: AboutPage, cache: false },
'/users/:id': { page: UserPage, cache: true },
}, view)
export default app// pages/user/index.ts
import { Div, http } from '@codesuma/baseline'
export const UserPage = () => {
const page = Div()
const name = Div()
const email = Div()
page.append(name, email)
page.on('enter', async ({ params }) => {
const { data } = await http.get(`/api/users/${params.id}`)
name.text(data.name)
email.text(data.email)
})
return page
}HTTP Client
Built-in fetch wrapper with request deduplication:
import { http } from '@codesuma/baseline'
// GET (automatically deduplicated - same URL = same request)
const { status, data } = await http.get('/api/users')
// POST, PUT, PATCH, DELETE
await http.post('/api/users', { name: 'John' })
await http.put('/api/users/1', { name: 'Jane' })
await http.patch('/api/users/1', { active: true })
await http.delete('/api/users/1')
// With auth
await http.get('/api/me', { auth: 'my-token' })
// Upload with progress
await http.upload('/api/upload', file, {
onProgress: (loaded, total) => {
console.log(`${Math.round(loaded/total * 100)}%`)
}
})Storage
localStorage
import { ldb } from '@codesuma/baseline'
ldb.set('user', { name: 'John' }) // Auto JSON stringify
ldb.get('user') // Auto JSON parse
ldb.remove('user')
ldb.clear()IndexedDB
import { idb } from '@codesuma/baseline'
const db = idb('my-app')
// Create store (run once on app init)
await db.createStore('users', 1, { keyPath: 'id', indices: ['email'] })
// CRUD
await db.save('users', { id: '1', name: 'John', email: '[email protected]' })
await db.save('users', [item1, item2, item3]) // Batch save
const user = await db.get('users', '1')
const allUsers = await db.all('users')
await db.delete('users', '1')
await db.clear('users')
await db.count('users')
// Query with options
const results = await db.find('users', {
index: 'email',
value: '[email protected]', // Exact match
limit: 10,
reverse: true
})
// Range queries
await db.find('products', { index: 'price', gt: 10, lt: 100 })
await db.find('products', { index: 'price', between: [10, 100] })
await db.find('products', { index: 'price', gte: 50 })
// Close connection when done (auto-closes after 2s idle)
db.close()Global State
import { state } from '@codesuma/baseline'
// Simple get/set
state.set('user', { name: 'John', id: 123 })
state.get('user') // { name: 'John', id: 123 }
// Subscribe to changes
state.on('user', (user) => {
console.log('User changed:', user)
})
// Subscribe to any change
state.on('change', ({ key, value }) => {
console.log(`${key} changed`)
})Helpers
Style Helpers
import { ABSOLUTE, FIXED, EASE, WH, Y, CENTER } from '@codesuma/baseline/helpers/style'
card.style({ ...ABSOLUTE }) // position: absolute; inset: 0;
card.style({ ...CENTER }) // display: flex; align-items: center; justify-content: center;
card.style({ ...EASE(0.3) }) // transition: all 0.3s ease;
card.style({ ...WH(100, 50) }) // width: 100px; height: 50px;
card.style({ ...Y(-10) }) // transform: translateY(-10px);Ripple Effect
Enable the ripple effect globally in your main entry file:
import { initRipple, Button } from '@codesuma/baseline'
// Initialize once
initRipple()
// Usage
const btn = Button('Click me')
btn.attr('data-ripple', '') // Add ripple attributeDevice Detection
import { isMobile, isTouch } from '@codesuma/baseline/helpers/device'
if (isMobile()) { /* mobile layout */ }
if (isTouch()) { /* touch interactions */ }Validation
import { isEmail } from '@codesuma/baseline/helpers/regex'
if (isEmail(input.value())) { /* valid */ }Project Structure
This repository uses a mono-repo layout with npm workspaces:
baseline/
├── packages/
│ └── baseline/ # The npm package (@codesuma/baseline)
│ ├── components/
│ │ ├── base.ts # Core factory
│ │ ├── native/ # Div, Button, Input, etc.
│ │ └── advanced/ # Page (with transitions)
│ ├── lib/ # Router, HTTP, State, Storage
│ ├── utils/ # Emitter, Appender, Styler
│ ├── helpers/ # Date, Device, Format, Regex
│ ├── index.ts # Public API
│ ├── package.json
│ └── README.md
├── examples/
│ └── demo-app/ # Example app (not published)
│ ├── components/
│ ├── pages/
│ ├── services/
│ ├── app.ts
│ ├── index.ts
│ ├── build/ # Example app build output
│ ├── rollup.config.js
│ └── tsconfig.json
├── package.json # Root workspace config
└── .gitignoreFor your own app, install the package and follow this structure:
my-app/
├── components/ # Your reusable components
│ └── card/
│ ├── index.module.css
│ └── index.ts
├── pages/ # Page components
│ ├── home/
│ │ └── index.ts
│ └── about/
│ └── index.ts
├── services/ # API, state management
├── app.ts # App setup with router
├── index.ts # Entry point
└── index.htmlDevelopment
Setup
# Clone the repo
git clone https://github.com/ardeshirvalipoor/base-ui.git
cd base-ui
# Install all workspace dependencies
npm install
# Build the example app (watches for changes)
npm run build:example
# Serve locally
npm run start:example
# Visit http://localhost:9876Workspace Structure
This project uses npm workspaces:
packages/baseline— The library (published to npm)examples/demo-app— Example app (not published, usesworkspace:*dependency)
The example app imports @codesuma/baseline like any consumer would, but npm workspaces resolve it to the local packages/baseline/ directory.
Publishing to npm
This project uses Changesets across the monorepo for automated versioning and publishing.
# From the root directory:
# 1. Document your changes
npm run changeset
# 2. Bump version and generate CHANGELOG.md
npm run version-packages
# 3. Publish to npm
npm run releaseOnly the files listed in package.json → files are published:
components/**/*helpers/**/*lib/**/*utils/**/*index.tsREADME.md
The examples/ directory and build output are never included in the npm package.
Philosophy
- Imperative over declarative - You control the DOM directly
- Explicit over implicit - No hidden state updates or re-renders
- Composition over inheritance - Mix capabilities with Object.assign
- Simplicity over features - Small API surface, easy to learn
MIT License
