Build custom views for LifeVis using HTML, CSS, and JavaScript.
A LifeVis add-on is a single HTML file (with inline CSS and JS) that renders inside a sandboxed WebView. Your add-on gets read-only access to the user's data through window.lifevis — a promise-based JavaScript API injected automatically by the app.
Add-ons can:
Here's an example add-on that demonstrates every API function. Copy this into the test harness to try it out.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline';
style-src 'unsafe-inline'; img-src data: blob:; font-src data:;">
<title>API Demo</title>
<style>
body { font-family: system-ui; padding: 20px; }
.card { display: flex; gap: 12px; margin-bottom: 16px; cursor: pointer; }
.card img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; }
.title { font-weight: 600; }
.meta { color: #888; font-size: 0.85em; }
.section { margin-top: 24px; }
.section h2 { font-size: 1.1em; margin-bottom: 8px; }
.tag { display: inline-block; background: #eef; padding: 2px 8px;
border-radius: 12px; font-size: 0.8em; margin: 2px; cursor: pointer; }
.city { display: inline-block; background: #efe; padding: 2px 8px;
border-radius: 12px; font-size: 0.8em; margin: 2px; }
</style>
</head>
<body>
<div id="app">Loading...</div>
<script>
async function init() {
const app = document.getElementById('app');
// ── getPosts: fetch posts filtered by type, sorted by date ──
const posts = await lifevis.getPosts({
sortBy: 'date',
sortOrder: 'desc'
});
// ── getTheme: adapt to light/dark mode ──
const theme = await lifevis.getTheme();
if (theme.isDark) document.body.style.background = '#1a1a2e';
if (theme.isDark) document.body.style.color = '#e0e0e0';
// ── getConfig / setConfig: persist add-on preferences ──
let config = await lifevis.getConfig();
config.viewCount = (config.viewCount || 0) + 1;
await lifevis.setConfig(config);
// ── Render posts with thumbnails ──
let html = '<h2>Recent Posts (' + posts.length + ')</h2>';
for (const post of posts.slice(0, 5)) {
// ── getThumbnail: fetch image as data URL by post ID ──
const thumb = await lifevis.getThumbnail(post.id);
const imgTag = thumb.data
? '<img src="' + thumb.data + '">'
: '';
// ── viewPost: tap a post to open the native detail view ──
html += '<div class="card" onclick="lifevis.viewPost(\'' + post.id + '\')">'
+ imgTag
+ '<div><div class="title">' + post.title + '</div>'
+ '<div class="meta">' + post.type
+ (post.cities && post.cities[0] ? ' · ' + post.cities[0] : '')
+ '</div></div></div>';
}
// ── getTags: fetch all tags, optionally filter to skills only ──
const tags = await lifevis.getTags();
html += '<div class="section"><h2>Tags (' + tags.length + ')</h2>';
tags.slice(0, 15).forEach(function(tag) {
// ── editTag: tap a tag to open the native tag editor ──
html += '<span class="tag" onclick="lifevis.editTag(\'' + tag.id + '\')">'
+ tag.name + '</span>';
});
html += '</div>';
// ── getCities: fetch all cities with coordinates ──
const cities = await lifevis.getCities();
html += '<div class="section"><h2>Cities (' + cities.length + ')</h2>';
cities.slice(0, 10).forEach(function(city) {
// ── switchView: navigate to a built-in tab ──
html += '<span class="city" onclick="lifevis.switchView(\'map\')">' + city.name + '</span>';
});
html += '</div>';
// ── getEnabledViews: check which built-in tabs are active ──
const views = await lifevis.getEnabledViews();
html += '<div class="section"><h2>Enabled Views</h2>'
+ '<div class="meta">' + views.join(', ') + '</div></div>';
html += '<div class="meta" style="margin-top:16px">View count: '
+ config.viewCount + '</div>';
app.innerHTML = html;
// ── onThemeChange: react when user toggles light/dark mode ──
lifevis.onThemeChange(function(t) {
document.body.style.background = t.isDark ? '#1a1a2e' : '#fff';
document.body.style.color = t.isDark ? '#e0e0e0' : '#1a1a2e';
});
// ── onDataChange: refresh when user edits/imports data ──
lifevis.onDataChange(function() { init(); });
// ── onShowSettings: open your settings UI when toolbar button tapped ──
lifevis.onShowSettings(function() {
alert('Settings panel would open here');
});
}
// Wait for the bridge, then initialize
if (window.lifevis) init();
else document.addEventListener('DOMContentLoaded', function() {
var check = setInterval(function() {
if (window.lifevis) { clearInterval(check); init(); }
}, 50);
});
</script>
</body>
</html>
This example uses every API function. The CSP must include img-src data: blob: for thumbnails. See the full API reference below for details on each function.
Each add-on has a manifest.json that describes it in the catalog and on disk after install:
{
"id": "com.yourname.myplugin",
"name": "My Plugin",
"icon": "chart.bar.fill",
"androidIcon": "bar_chart",
"version": "1.0",
"entryPoint": "MyPlugin",
"author": "Your Name",
"description": "A brief description of what your add-on shows."
}
| Field | Type | Required | Description |
|---|---|---|---|
id | String | Yes | Reverse-domain ID (must be unique) |
name | String | Yes | Display name shown in the tab bar and store |
icon | String | Yes | SF Symbol name for the tab icon (iOS/macOS) |
androidIcon | String | No | Material Icon name for the tab icon (Android). Falls back to a generic icon if omitted. |
version | String | Yes | Semver version string |
entryPoint | String | Yes | HTML filename without extension (e.g. "MyPlugin" loads MyPlugin.html) |
author | String | Yes | Developer or company name |
description | String | Yes | Short description for the store listing |
LifeVis runs on iOS/macOS (SF Symbols) and Android (Material Icons). Here are some common equivalents:
| Purpose | icon (SF Symbol) | androidIcon (Material) |
|---|---|---|
| Document | doc.text | description |
| Calendar | calendar | calendar_month |
| Chart (bar) | chart.bar.fill | bar_chart |
| Chart (pie) | chart.pie | pie_chart |
| Map | map | map |
| Person | person | person |
| Tag | tag | label |
| Photo | photo | photo |
| List | list.bullet | format_list_bulleted |
| Clock/History | clock | schedule |
Every add-on HTML file must include the Content Security Policy meta tag. This is also enforced natively, but including it in your HTML ensures consistent behavior during development.
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline';
style-src 'unsafe-inline'; img-src data: blob:; font-src data:;">
This means:
data: or blob: URIs — thumbnails come through the bridge as data URIsThe bridge is available at window.lifevis. All data methods return Promises.
| Method | Returns | Description |
|---|---|---|
getPosts(options?) |
Post[] |
Get posts. Options: { type, types, sortBy, sortOrder } |
getTags(options?) |
Tag[] |
Get tags/skills. Options: { isSkill, category } |
getCities() |
City[] |
Get all cities with coordinates |
getTheme() |
{ isDark } |
Get current theme mode |
getThumbnail(postId) |
{ data } |
Get base64 thumbnail. Returns { data: "data:image/..." } or { data: null } |
| Option | Type | Description |
|---|---|---|
type | String | Filter by single type: "post", "job", "education", "trip", "residence", "project" |
types | String[] | Filter by multiple types |
sortBy | String | "date" (default) or "startDate" |
sortOrder | String | "desc" (default) or "asc" |
| Option | Type | Description |
|---|---|---|
isSkill | Boolean | If true, only return skills (not regular tags) |
category | String | Filter by skill category (e.g. "Programming", "Languages") |
| Method | Description |
|---|---|
viewPost(id) | Open the native post detail sheet |
editPost(id) | Open the native post editor |
editTag(id) | Open the native tag/skill editor |
Pass the post or tag id string (UUID) received from getPosts() or getTags().
// Make a job entry clickable element.onclick = () => lifevis.viewPost(job.id);
| Method | Description |
|---|---|
getConfig() | Get this add-on's stored config object |
setConfig(obj) | Save config (replaces entire object) |
{
id: "uuid-string",
title: "Post title",
type: "job", // post, job, education, trip, residence, project
date: "2024-01-15T00:00:00Z",
imageCount: 2,
// Optional fields (present when applicable):
startDate: "2022-06-01T00:00:00Z",
endDate: "2024-01-15T00:00:00Z",
company: "Acme Corp", // jobs
position: "Engineer", // jobs
school: "MIT", // education
degree: "BS", // education
fieldOfStudy: "CS", // education
content: "<p>HTML...</p>",
source: "linkedin",
cities: ["San Francisco", "New York"],
tags: ["Python", "AWS"]
}
{
id: "uuid-string",
name: "Python",
postCount: 12,
isSkill: true,
level: 8, // 1-10 proficiency
category: "Programming", // optional
proficiency: "Expert", // optional display label
description: "..." // optional skill description
}
{
id: "uuid-string",
name: "San Francisco",
latitude: 37.7749,
longitude: -122.4194,
postCount: 45
}
Register callbacks to respond to app events:
| Method | Callback | Description |
|---|---|---|
onThemeChange(cb) | cb(isDark) | Light/dark mode changed |
onConfig(cb) | cb(configObj) | Stored config delivered on load |
onDataChange(cb) | cb() | User's data changed (after import or edit) |
onShowSettings(cb) | cb() | User tapped the settings button in the toolbar |
// Re-render when the user imports new data
lifevis.onDataChange(function() {
init(); // re-fetch and redraw
});
// Respond to theme changes
lifevis.onThemeChange(function(isDark) {
document.documentElement.classList.toggle('dark', isDark);
});
Add-ons can save settings that persist across app launches. Config is a plain JSON object, scoped to your add-on.
// Save user preferences
await lifevis.setConfig({
showPhotos: true,
sortOrder: 'date',
accentColor: '#2563eb'
});
// Load on startup
lifevis.onConfig(function(config) {
// config = { showPhotos: true, sortOrder: 'date', ... }
applySettings(config);
});
// Or fetch at any time
const config = await lifevis.getConfig();
setConfig() replaces the entire config object. To update a single field, read the current config first, merge, then save.
If your add-on has user-configurable options, implement a settings panel that opens when the user taps the toolbar settings button. This is typically a drawer or modal rendered in your HTML.
lifevis.onShowSettings(function() {
// Show your settings drawer/modal
document.getElementById('settings-drawer').classList.add('open');
});
// When the user changes a setting:
function onSettingChanged() {
const newConfig = gatherSettings();
lifevis.setConfig(newConfig);
render(); // re-render with new settings
}
The app provides a gear icon in the toolbar automatically. When tapped, it calls your onShowSettings callback.
Add-ons should support both light and dark mode. The recommended approach is CSS custom properties with a .dark class:
:root {
--bg: #ffffff;
--text: #1a1a1a;
--accent: #2563eb;
}
.dark {
--bg: #161618;
--text: #f0f0f0;
--accent: #60a5fa;
}
body { background: var(--bg); color: var(--text); }
// Apply theme on load
lifevis.getTheme().then(function(t) {
document.documentElement.classList.toggle('dark', t.isDark);
});
// React to changes
lifevis.onThemeChange(function(isDark) {
document.documentElement.classList.toggle('dark', isDark);
});
Add-ons can optionally support rendering in the web viewer (for shared links). When your add-on runs in the web viewer, it loads inside an iframe and receives data through a different bridge: window.parent provides the API instead of window.webkit.
To detect the web viewer context:
const isWebViewer = (window.parent !== window);
In the web viewer, window.lifevis is provided by the parent frame with compatible methods. Native actions (viewPost, editPost, editTag) and settings are not available — hide those controls when running in the web viewer.
To enable web viewer support, your add-on's catalog entry needs:
{
"webSupported": true,
"webURL": "https://relay.mylifevis.com/addons/com.yourname.myplugin/1.0.0/MyPlugin.html"
}
The web viewer provides search filtering. To support it:
// In the web viewer, listen for search changes
if (isWebViewer && lifevis.onSearchChange) {
lifevis.onSearchChange(function(query) {
filterAndRender(query);
});
}
You don't need the LifeVis app or a server to develop and test add-ons. We provide a test harness that mocks the entire window.lifevis bridge API with realistic sample data. Your add-on runs in a browser exactly as it would inside the app.
Open the test harness directly in your browser: Launch Test Harness
Two ways to load your add-on:
.html file from diskNo server, no install, no setup. The harness injects a mock window.lifevis bridge with sample data including:
onThemeChangeonSearchChange integrationonShowSettings to test your settings UIonDataChange to test reload behaviorgetConfig/setConfig use localStorage so settings survive page reloadsviewPost, editPost, editTag are logged visually so you can verify interactionsviewPost/editTag callswindow.parent !== window will be true. To test the native app context (where isWebViewer is false), open your add-on HTML directly in the browser — the bridge won't be available, but you can test layout and styling.
A add-on bundle is a .zip file containing:
MyPlugin.zip ├── MyPlugin.html (required: entry point) ├── manifest.json (written automatically on install, but included for reference) └── (optional additional assets)
Requirements:
entryPoint field in the manifestTo submit your add-on:
.zip bundle (hosted on your own server, GitHub, Dropbox, etc.)We'll review your submission for security compliance (no network requests, no path traversal, CSP present) and functionality, then add it to the catalog.
Add-ons can be listed as free or paid in the LifeVis add-on store.
Free add-ons are available to all LifeVis users at no cost. This is the fastest way to get your add-on listed and build a user base.
Paid add-ons use Apple In-App Purchase, processed through the LifeVis app. When submitting, propose one of the available price tiers:
| Price Tier | You Receive (70%) | LifeVis (30%) |
|---|---|---|
| $0.99 | ~$0.49 | ~$0.21 |
| $1.99 | ~$0.97 | ~$0.42 |
| $2.99 | ~$1.46 | ~$0.63 |
| $4.99 | ~$2.44 | ~$1.05 |
| $9.99 | ~$4.89 | ~$2.10 |
Amounts shown are after Apple's 30% platform fee (15% for qualifying small businesses). Revenue share is 70% developer / 30% LifeVis on net revenue.
Have an add-on idea? Questions about the API? Use the contact form — we're happy to help.