A Streamlit-style UI framework that depends only on Web standards and Hono.
- Simple - Declarative API like Streamlit (
kt.button(),kt.slider(), etc.) - Real-time - Instant UI updates via WebSocket with multi-tab sync support
- Lightweight - Depends only on Hono, supports multiple runtimes (Bun, Node.js, Deno)
- Session Management - Automatic state management for multiple users
- Connection Stability - Ping/Pong, auto-reconnect, sequence-based patch recovery
- Streaming - Progressive rendering for large UIs
- Security - Magic byte validation, polyglot detection, and XSS protection for file uploads
- Bun v1.0+ (recommended)
- Or Node.js v18+, Deno
bun installbun run devOpen http://localhost:3000 in your browser to see the demo app.
import { createApp, kt, createTypedSessionState } from "kantan-ui";
// Define type-safe session state
type AppState = {
count: number;
};
const state = createTypedSessionState<AppState>({
count: 0, // Default value
});
const script = () => {
kt.title("Counter App");
// Button returns true when clicked
if (kt.button("+ Increment")) {
state.count++; // Type-safe! No type assertion needed
}
kt.write(`Count: ${state.count}`);
// Return undefined when using declarative API
return undefined;
};
// All runtimes: create app with await createApp
export default await createApp(script, { port: 3000 });Bun (Recommended)
bun run src/index.tsBun automatically starts a server when export default returns an object with fetch/websocket/port.
Node.js
import { createApp } from "kantan-ui";
import { serve } from "kantan-ui/serve";
const app = await createApp(script);
serve(app, { port: 3000 });The kt object lets you build UI intuitively like Streamlit. Each function automatically outputs HTML and returns appropriate values.
import { kt } from "kantan-ui";
kt.title("Title"); // <h1>
kt.header("Header"); // <h2>
kt.subheader("Subheader"); // <h3>
kt.write("Text"); // Text output
kt.text("Text"); // Alias for write
kt.divider(); // Horizontal rule <hr>
kt.html("<div>Raw HTML</div>"); // Raw HTML output (caution: XSS risk)
kt.markdown("**Bold** text"); // Markdown rendering
kt.code("const x = 1;", "typescript"); // Code block with syntax highlighting
kt.json({ key: "value" }); // Collapsible JSON viewerDisplay text progressively from streaming sources (ideal for LLM responses).
// AsyncGenerator (LLM-style streaming)
async function* generateResponse() {
yield "Hello, ";
await new Promise(r => setTimeout(r, 100));
yield "World!";
}
const fullText = await kt.write_stream(generateResponse());
// Array (instant display)
await kt.write_stream(["Item 1, ", "Item 2, ", "Item 3"]);
// With Markdown rendering on completion
await kt.write_stream(["# Title\n", "\nThis is **bold** text."], {
markdown: true,
});
// Custom CSS class
await kt.write_stream(chunks, { className: "my-stream" });
// ReadableStream (Web standard)
const stream = new ReadableStream<string>({
start(controller) {
controller.enqueue("Streaming...");
controller.close();
}
});
await kt.write_stream(stream);
// Response from fetch
const response = await fetch("/api/stream");
await kt.write_stream(response);Supported Sources:
AsyncIterable<string>- async generators, LLM streamsIterable<string>- arrays, iteratorsReadableStream<string>- Web standard streamsResponse- fetch API responses- Factory functions returning any of the above
Options:
markdown: boolean- Render as Markdown when stream completesclassName: string- Custom CSS class for styling
Features:
- Blinking cursor during streaming
- Automatic cursor removal on completion
- XSS-safe text rendering
- Multiple concurrent streams supported
kt.success("Operation completed!");
kt.error("Something went wrong");
kt.warning("Please check your input");
kt.info("Here's some information");// Progress bar (0-1 or 0-100 auto-detected)
kt.progress(0.75);
kt.progress(75, { label: "Downloading..." });
// Loading spinner
kt.spinner("Processing...");
// Toast notification
kt.toast("Saved successfully!");
kt.toast("Error occurred", { type: "error" });// Table - supports various data formats
kt.table([
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
]);
// 2D array format
kt.table([
["Name", "Age"],
["Alice", 30],
["Bob", 25],
]);
// Explicit header specification
kt.table({
columns: ["Name", "Age"],
data: [["Alice", 30], ["Bob", 25]],
});
// Metric - KPI display with optional delta
kt.metric("Revenue", "$1,234");
kt.metric("Revenue", "$1,234", { delta: "+12%" });
kt.metric("Response Time", "120ms", { delta: "+15ms", delta_color: "inverse" });// Page settings (title, layout, etc.)
kt.set_page_config({
title: "My App",
layout: "wide", // "centered" | "wide"
icon: "🚀",
});
// Force script re-execution
kt.rerun();// Button - returns true when clicked
if (kt.button("Click me", { key: "my_button" })) {
// Handle button click
}
// Slider - returns current value
const volume = kt.slider("Volume", 0, 100, 50, { key: "volume" });
// Text input - returns current input value
const name = kt.text_input("Your name", "Default", { key: "name" });
// Selectbox - returns selected value
const color = kt.selectbox("Color", ["Red", "Green", "Blue"], "Blue", { key: "color" });
// Download button - triggers file download
kt.download_button("Download CSV", "name,age\nAlice,30", "data.csv", {
mime: "text/csv",
});
// Checkbox - returns boolean
const agreed = kt.checkbox("I agree", false, { key: "agree" });
// Toggle - returns boolean (switch style)
const darkMode = kt.toggle("Dark mode", false, { key: "dark_mode" });
// Radio buttons - returns selected value
const size = kt.radio("Size", ["S", "M", "L"], "M", { key: "size" });
// Number input - returns number
const age = kt.number_input("Age", 0, 120, 25, { key: "age", step: 1 });
// Text area - returns multiline text
const bio = kt.text_area("Bio", "Tell us about yourself...", { key: "bio" });
// Multiselect - returns array of selected values
const tags = kt.multiselect("Tags", ["Tech", "Design", "Business"], [], { key: "tags" });
// Date input - returns "YYYY-MM-DD" string
const birthday = kt.date_input("Birthday", "2000-01-15", {
min: "1900-01-01",
max: "2024-12-31",
});
// Time input - returns "HH:MM" string
const alarm = kt.time_input("Alarm", "08:30", { step: 60 });
// File uploader - returns UploadedFile object
const file = kt.file_uploader("Upload file", { accept: "image/*", maxSize: 5 * 1024 * 1024 });
if (file) {
kt.write(`Uploaded: ${file.name} (${file.size} bytes)`);
const content = file.text(); // or file.arrayBuffer()
}
// Multiple files
const files = kt.file_uploader("Upload files", { multiple: true });
for (const f of files) {
kt.write(`${f.name}: ${f.type}`);
}// Tabs - organize content in multiple tabs
const [tab1, tab2, tab3] = kt.tabs(["Overview", "Data", "Settings"]);
tab1(() => {
kt.header("Overview");
kt.write("This is the overview tab.");
});
tab2(() => {
kt.header("Data");
kt.table(data);
});
tab3(() => {
kt.header("Settings");
kt.write("Configure your preferences here.");
});
// Columns - create multi-column layout
kt.columns([
() => kt.write("Left"),
() => kt.write("Right"),
]);
// With ratios (1:2:1 = 25%:50%:25%)
kt.columns(
[
() => kt.write("Sidebar"),
() => kt.write("Main content"),
() => kt.write("Sidebar"),
],
{ ratios: [1, 2, 1] }
);
// Container - group content
kt.container(() => {
kt.write("Grouped content");
kt.button("Action");
}, { border: true });
// Expander - collapsible section
kt.expander("See details", () => {
kt.write("Hidden content");
});
// Expanded by default
kt.expander("Important notice", () => {
kt.write("Please read this!");
}, { expanded: true });// Callback notation
kt.sidebar(() => {
kt.title("Settings");
kt.button("Reset");
});
// Object notation
kt.sidebar.title("Settings");
kt.sidebar.button("Reset");
// Custom width
kt.sidebar(() => {
kt.title("Wide Sidebar");
}, { width: "350px" });// Form with submit button
kt.form("login_form", () => {
const username = kt.text_input("Username");
const password = kt.text_input("Password", "", { type: "password" });
if (kt.form_submit_button("Login")) {
// Handle form submission
}
});
// Validation errors
kt.form("contact", () => {
const email = kt.text_input("Email");
if (kt.form_submit_button("Send")) {
if (!email.includes("@")) {
kt.validation_error("Please enter a valid email address");
return;
}
// Process valid form data
}
});
// Multiple validation errors
kt.form("signup", () => {
const name = kt.text_input("Name");
const email = kt.text_input("Email");
if (kt.form_submit_button("Sign Up")) {
const errors = [];
if (!name) errors.push("Name is required");
if (!email) errors.push("Email is required");
if (errors.length > 0) {
kt.validation_errors(errors);
return;
}
}
});import { kt, createTypedSessionState } from "kantan-ui";
// Message history type definition
type ChatState = {
messages: Array<{ role: "user" | "assistant"; content: string }>;
};
const state = createTypedSessionState<ChatState>({
messages: [],
});
// Chat container (with auto-scroll)
kt.chat_container(() => {
for (const msg of state.messages) {
kt.chat_message(msg.role, msg.content);
}
}, { height: "400px" });
// Individual chat messages
kt.chat_message("user", "Hello!");
kt.chat_message("assistant", "Hi! How can I help you?");
// Custom avatar and name
kt.chat_message("user", "What is **TypeScript**?", {
name: "Alice",
avatar: "🧑💻",
});
// System message
kt.chat_message("system", "Session started at 10:00 AM");Features:
- Role-based styling (user / assistant / system)
- Markdown content support
- Customizable avatar and display name
- Auto-scroll (pauses when user scrolls up)
Create dynamically updatable placeholders. Similar to Streamlit's st.empty().
// Create placeholder
const status = kt.empty({ key: "status" });
// Dynamically change content on button click
if (kt.button("Start Process")) {
status.spinner("Processing...");
}
if (kt.button("Complete")) {
status.success("Done!");
}
if (kt.button("Show Error")) {
status.error("Something went wrong!");
}
if (kt.button("Clear")) {
status.empty(); // Clear content
}
// Show progress bar
const progress = kt.empty({ key: "progress" });
if (kt.button("Show Progress")) {
progress.progress(0.75, { text: "75% complete" });
}Placeholder Object Methods:
write(content)- Display text/number/booleantext(content)- Display plain textmarkdown(content)- Display Markdownhtml(content)- Display raw HTMLjson(data)- Display formatted JSONcode(content, language?)- Display code blocksuccess(message)- Success alerterror(message)- Error alertwarning(message)- Warning alertinfo(message)- Info alertprogress(value, config?)- Progress bar (0.0-1.0)spinner(text?)- Loading spinnerempty()- Clear content
Manage per-user session state. Similar to Streamlit's st.session_state.
Create type-safe session state. Access safely without type assertions, with IDE completion support.
import { createTypedSessionState } from "kantan-ui";
// Define type and specify defaults
type AppState = {
counter: number;
name: string;
items: string[];
};
const state = createTypedSessionState<AppState>({
counter: 0,
name: "World",
items: [],
});
// Type-safe access
state.counter++; // OK - number type
state.name = "Hello"; // OK - string type
state.items.push("item"); // OK - string[] type
// state.unknown = 1; // Compile error!For dynamic keys, the traditional session_state is also available.
import { session_state } from "kantan-ui";
// Initialize
if (session_state.myValue === undefined) {
session_state.myValue = "initial";
}
// Read (type assertion required)
const value = session_state.myValue as string;
// Write
session_state.myValue = "new value";Cache expensive function results to improve performance. Similar to Streamlit's @st.cache_data and @st.cache_resource.
For serializable data (API results, computed values):
import { kt } from "kantan-ui";
// Basic usage
const fetchUsers = kt.cache_data(async (limit: number) => {
const res = await fetch(`/api/users?limit=${limit}`);
return res.json();
});
const users = await fetchUsers(10); // Cached on subsequent calls
// With TTL (expires in 1 hour)
const fetchWeather = kt.cache_data(async (city: string) => {
return await weatherApi.get(city);
}, { ttl: 3600 });
// With max entries (LRU eviction)
const searchProducts = kt.cache_data(async (query: string) => {
return await productApi.search(query);
}, { max_entries: 50 });
// Clear cache
fetchUsers.clear();For non-serializable resources (database connections, ML models):
// Returns the same instance on every call
const getDb = kt.cache_resource(() => {
return new DatabaseConnection(process.env.DB_URL);
});
const db1 = getDb();
const db2 = getDb();
console.log(db1 === db2); // truekt.cache_data.clear(); // Clear all cache_data caches
kt.cache_resource.clear(); // Clear all cache_resource caches
kt.clear_all_caches(); // Clear all cachesFor finer control, imperative APIs are also available.
import { button, renderButton, slider, renderSlider } from "kantan-ui";
// Functional API (returns true when pressed)
const pressed = button("Click me", { key: "my_button" });
// For rendering (returns HTML)
const html = renderButton("Click me", { key: "my_button" });src/
├── index.ts # Entry point (exports)
├── app.ts # createApp function
├── server.ts # Demo server
├── client/ # Client script generation
│ ├── script.ts # WebSocket/event handling script
│ ├── types.ts # Client config types
│ └── index.ts
├── config/ # Configuration management
│ ├── defaults.ts # Default settings
│ ├── types.ts # Config types
│ └── index.ts
├── kt/ # Declarative API (Streamlit-style)
│ ├── context.ts # Render context
│ ├── config.ts # Page config (set_page_config)
│ ├── control.ts # Control API (rerun)
│ ├── chat.ts # Chat API (chat_message, chat_container)
│ ├── data.ts # Data display (table)
│ ├── empty.ts # Empty placeholder
│ ├── feedback.ts # Feedback API (progress, spinner, toast)
│ ├── form.ts # Form API
│ ├── layout.ts # Layout (tabs, columns, container, expander)
│ ├── sidebar.ts # Sidebar API
│ ├── output.ts # Output API (title, write, header, etc.)
│ ├── stream.ts # Streaming API (write_stream)
│ ├── stream-utils.ts # Stream normalization utilities
│ ├── stream-registry.ts # Pending stream management
│ ├── widgets.ts # Widget API (button, slider, etc.)
│ └── index.ts
├── runtime/ # Runtime context management
│ ├── context.ts # getContext/setContext
│ ├── rerun.ts # Script re-execution logic
│ ├── stream-processor.ts # Stream processing engine
│ └── index.ts
├── session/ # Session management
│ ├── manager.ts # SessionManager (multi-tab support)
│ ├── state.ts # session_state (Proxy implementation)
│ ├── types.ts # Type definitions
│ └── index.ts
├── utils/ # Utilities
│ ├── html.ts # HTML escaping, etc.
│ ├── sanitize.ts # Filename sanitization
│ ├── magic-bytes.ts # Magic byte validation
│ ├── polyglot-detection.ts # Polyglot detection
│ ├── file-validation.ts # File validation integration
│ └── type-guards.ts
├── websocket/ # WebSocket handling
│ ├── handler.ts # WebSocket handler
│ ├── types.ts # Message type definitions
│ └── index.ts
└── widgets/ # UI widgets (imperative API)
├── button.ts
├── slider.ts
├── text-input.ts
├── text-area.ts
├── selectbox.ts
├── download-button.ts
├── checkbox.ts
├── toggle.ts
├── radio.ts
├── number-input.ts
├── multiselect.ts
├── date-input.ts
├── time-input.ts
├── file-uploader.ts
├── uploaded-file.ts
├── image.ts
├── placeholder.ts
├── core.ts
├── registry.ts
├── types.ts
└── index.ts
| Command | Description |
|---|---|
bun run dev |
Start development server (hot reload) |
bun run build |
Production build |
bun run test |
Run unit tests (Vitest) |
bun run test:watch |
Unit tests (watch mode) |
bun run test:coverage |
Tests with coverage |
bun run test:e2e |
E2E tests (Playwright) |
bun run lint |
Lint check with Biome |
bun run lint:fix |
Auto-fix lint issues |
bun run ci |
CI pipeline (lint + build + test:coverage) |
- Client loads the page and establishes WebSocket connection
- Server creates a session and sends initial HTML
- When user interacts with widgets,
sendEventnotifies the server (with debounce) - Server updates
session_stateand re-executes the script (rerun) - New HTML is sent to client via WebSocket (streaming supported)
- Client updates DOM (preserving focus state)
- Ping/Pong: Server periodically sends ping to monitor connection state
- Auto-reconnect: Exponential backoff retry on disconnect
- Sequence Numbers: Recover missed patches on reconnect
- Multi-tab: Broadcast state changes to all tabs of the same session
MIT