Tendril
Obsidian-style knowledge graphs for the web
Because your blog shouldn't feel like a list
The Problem
If you use Obsidian, you know the graph view. That floating, organic visualization where ideas connect across your vault. Tags form clusters. Posts link to each other. You can spend hours just... looking at it.
Then you publish your notes to the web, and that graph is gone. Your beautiful knowledge graph becomes a chronological list. Linear. Static. Boring.
Tendril fixes that.
See It In Action
Quick Start
npm install @tendril/graph cytoscape import { TendrilGraph } from '@tendril/graph';
const graph = new TendrilGraph('#container', {
nodes: [
{ id: 'post-1', label: 'My First Post', type: 'post' },
{ id: 'post-2', label: 'Another Post', type: 'post' },
{ id: 'tag-js', label: 'JavaScript', type: 'tag' },
],
edges: [
{ source: 'post-1', target: 'tag-js' },
{ source: 'post-2', target: 'tag-js' },
],
physics: {
centerForce: 0.1,
repelForce: 1500
}
}); Physics Configuration Reference
The physics engine is what makes Tendril feel alive. Every parameter controls a different aspect of how nodes move, attract, and repel. Get these wrong and you get a tangled mess or a boring grid. Get them right and it feels like Obsidian.
| Parameter | Default | Range | Description |
|---|---|---|---|
centerForce | 0.05 | 0.01 - 0.5 | Gravity pulling nodes to center. Low = organic spread, high = tight cluster |
repelForce | 4000 | 500 - 10000 | Node repulsion strength. High = more spacing between nodes |
linkForce | 1.0 | 0.1 - 5.0 | Edge spring strength. How aggressively edges pull connected nodes together |
linkDistance | 40 | 10 - 200 | Base edge length in pixels. Overridden by spine/bridge classification |
damping | 0.85 | 0.5 - 0.99 | Velocity dampening per tick. Higher = faster settling, lower = more drift |
Preset Configurations
Starting points for common content structures. Copy one and tweak from there.
physics: {
centerForce: 0.1,
repelForce: 2000,
linkForce: 1.5,
linkDistance: 30,
damping: 0.9
} High center force keeps everything visible. Lower repulsion prevents posts from scattering when there are few tags to anchor them.
physics: {
centerForce: 0.05,
repelForce: 4000,
linkForce: 1.0,
linkDistance: 40,
damping: 0.85
} Default settings. Designed for densely connected graphs where clusters form naturally around shared tags.
physics: {
centerForce: 0.2,
repelForce: 1500,
linkForce: 2.0,
linkDistance: 60,
damping: 0.92
} Strong gravity and link forces keep the hierarchy readable. Higher damping settles fast so users see structure immediately.
Spine/Bridge Classification
This is the trick that makes Tendril graphs look organic instead of mechanical. Every other graph library treats all edges the same -- same length, same stiffness. The result is a uniform blob that tells you nothing about structure.
Tendril classifies each edge as either a spine or a bridge, and renders them differently. The result is the "sea urchin" geometry: dense hubs with radiating satellites, connected by loose bridges.
Spines
Edges involving leaf nodes (degree ≤ 2). These are posts connected to only one or two tags. Spines are short (length: 5) and pull tight, creating dense clusters around hub nodes.
5 Behavior: tight cluster Bridges
Edges between highly-connected hubs (both nodes degree > 2). These are the connections between tag clusters. Bridges are long (length: 150) and loose, creating visible separation between topic areas.
150 Behavior: loose connection // For each edge, check if either endpoint is a leaf
let sourceDegree = graph.degree(source);
let targetDegree = graph.degree(target);
let isLeafConnection = sourceDegree <= 2 || targetDegree <= 2;
let length = isLeafConnection ? 5 : 150;
// Spines: short, tight clusters around hubs
// Bridges: long, loose connections between hubs
edge.style('target-distance-from-node', length); Features
Obsidian-Style Physics
Tuned force-directed layout that feels organic. Nodes drift, attract, and repel naturally.
Framework Agnostic
Works with React, Vue, Svelte, Astro, or vanilla JavaScript. Zero framework dependencies.
Dark Mode Ready
Automatic theme detection with customizable colors for light and dark modes.
Configurable Physics
Fine-tune gravity, repulsion, edge elasticity, and damping to match your content density.
Navigation Ready
Click handlers for nodes let users navigate directly to content. Deep linking built in.
Fullscreen Mode
Expand to fullscreen with optional content panel for in-graph reading.
The Story
Tendril started as a late-night hack in April 2025. I was staring at my blog, wishing it had the same knowledge graph that made my Obsidian vault so engaging.
The first version was ugly. Nodes collapsed together. Text overlapped. The physics felt mechanical. It took months of tuningβadjusting repulsion forces, tweaking edge elasticity, fighting with z-indexβto make it feel right.
In January 2026, after 9 months of dogfooding it on this blog, I extracted the core into a standalone library. If I wanted an Obsidian-style graph on my website, maybe others did too.
Architecture
Why Cytoscape.js?
- Battle-tested physics engine (used in bioinformatics for decades)
- CSS-like styling selectors
- Clean event system
- Active community and extensions
Monorepo Structure
@tendril/graphβ Core library (framework-agnostic)template/β Full Astro starter with example posts@tendril/astroβ Astro integration (planned)
API Reference
The full TendrilGraph API. Everything you need to create, control, and destroy graph instances.
Constructor
const graph = new TendrilGraph(selector, options);
// selector: string | HTMLElement
// CSS selector or DOM element for the container
//
// options: {
// nodes: Array<{ id, label, type, url? }>,
// edges: Array<{ source, target }>,
// physics?: { centerForce, repelForce, linkForce, linkDistance, damping },
// onNodeClick?: (data) => void,
// onNodeHover?: (data) => void,
// theme?: 'dark' | 'light' | 'auto'
// } Methods
graph.setPhysics(params)Update physics parameters at runtime. Accepts a partial object -- only the properties you pass will change. The simulation restarts automatically.
graph.setPhysics({ repelForce: 6000, damping: 0.9 }); graph.enablePhysics(bool)Toggle the physics simulation on or off. When disabled, nodes stay where they are and can only be moved by dragging. Useful for "freeze" functionality.
graph.enablePhysics(false); // Freeze the graph
graph.enablePhysics(true); // Resume simulation graph.filterByType(type)Filter visible nodes by type. Pass 'post', 'tag', or 'all' to show/hide node categories. Edges are automatically hidden when both endpoints are hidden.
graph.filterByType('tag'); // Show only tag nodes
graph.filterByType('all'); // Show everything graph.fit(padding?)Fit the entire graph into the viewport with optional padding in pixels. Defaults to 50px padding. Animated over 200ms.
graph.fit(80); // Fit with 80px padding graph.zoom(factor)Set the zoom level programmatically. 1.0 is the default zoom. Values below 1 zoom out, above 1 zoom in.
graph.zoom(1.5); // Zoom in to 150% graph.reset()Reset the graph to its initial state. Restores default physics, zoom, pan, and filter settings. Nodes return to their layout positions.
graph.destroy()Clean up the graph instance. Removes all event listeners, stops the physics simulation, and clears the container. Always call this when unmounting in frameworks like React.
Events
const graph = new TendrilGraph('#container', {
nodes: [...],
edges: [...],
onNodeClick: (data) => {
// data: { id, label, type, url, position }
console.log('Clicked:', data.label);
if (data.url) window.location.href = data.url;
},
onNodeHover: (data) => {
// data: { id, label, type, url, position } | null
// null when hover leaves a node
if (data) showTooltip(data);
else hideTooltip();
}
}); Framework Integration
Tendril works with anything that renders to the DOM. Here are copy-paste examples for the most common setups.
---
// Runs at build time - fetch your content here
const posts = await getCollection('posts');
const nodes = posts.map(p => ({
id: p.slug, label: p.data.title, type: 'post', url: `/blog/${p.slug}`
}));
---
<div id="knowledge-graph" style="height: 500px;"></div>
<script define:vars={{ nodes }}>
import { TendrilGraph } from '@tendril/graph';
const graph = new TendrilGraph('#knowledge-graph', {
nodes,
edges: generateEdges(nodes),
physics: { centerForce: 0.05, repelForce: 4000 }
});
</script> import { useEffect, useRef } from 'react';
import { TendrilGraph } from '@tendril/graph';
function KnowledgeGraph({ nodes, edges }) {
const ref = useRef(null);
useEffect(() => {
const graph = new TendrilGraph(ref.current, {
nodes,
edges,
onNodeClick: (data) => {
if (data.url) window.location.href = data.url;
}
});
return () => graph.destroy(); // Clean up on unmount
}, [nodes, edges]);
return <div ref={ref} style={{ height: '500px' }} />;
} <script src="https://unpkg.com/@tendril/graph"></script>
<div id="graph" style="height: 500px;"></div>
<script>
new TendrilGraph('#graph', {
nodes: [
{ id: 'n1', label: 'First Post', type: 'post' },
{ id: 'n2', label: 'Second Post', type: 'post' },
{ id: 't1', label: 'JavaScript', type: 'tag' }
],
edges: [
{ source: 'n1', target: 't1' },
{ source: 'n2', target: 't1' }
]
});
</script> Comparison
How Tendril stacks up against other ways to get a knowledge graph on the web.
| Feature | Tendril | Quartz | Obsidian Publish |
|---|---|---|---|
| Live physics controls | Yes | No | No |
| Inline content preview | Yes | No | Separate pane |
| Framework agnostic | Yes | No (Hugo only) | N/A |
| Customizable forces | Yes | No | No |
| Open source | MIT | MIT | Proprietary |
| Cost | Free | Free | $8/month |
| Bundle size | ~15KB | N/A (full site) | N/A (hosted) |
| Spine/bridge physics | Yes | No | No |
Customization
Tendril uses Cytoscape.js selectors under the hood, so you can style every aspect of the graph using familiar CSS-like syntax.
Custom Node Colors by Type
Differentiate posts, tags, and any custom types with distinct colors.
new TendrilGraph('#graph', {
nodes: [...],
edges: [...],
styles: {
node: {
post: {
color: '#3b82f6', // Blue for posts
shape: 'ellipse',
size: 20
},
tag: {
color: '#10b981', // Green for tags
shape: 'round-rectangle',
size: 28
},
category: {
color: '#f59e0b', // Amber for categories
shape: 'diamond',
size: 24
}
}
}
}); Edge Styling
Control edge colors, widths, and opacity. Spines and bridges can be styled independently.
styles: {
edge: {
color: 'rgba(148, 163, 184, 0.3)',
width: 1,
// Highlight edges on connected node hover
activeColor: '#10b981',
activeWidth: 2
}
} Container Theming
The graph container inherits your page styles. Set a background and border to match your design system.
#knowledge-graph {
height: 500px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
} Dark / Light Mode
Set theme: 'auto' to follow the user's system preference, or force a specific mode.
new TendrilGraph('#graph', {
theme: 'auto', // 'dark' | 'light' | 'auto'
darkColors: {
background: '#0f172a',
nodePost: '#3b82f6',
nodeTag: '#10b981',
edge: 'rgba(148, 163, 184, 0.3)',
text: '#f1f5f9'
},
lightColors: {
background: '#ffffff',
nodePost: '#2563eb',
nodeTag: '#059669',
edge: 'rgba(100, 116, 139, 0.3)',
text: '#1e293b'
}
});