Force Graph Navigation in Next.js
Written by Adrian Tejada
30 Minutes — 15/2/2024
Source codelive DemoHi, and welcome to my first ever blog post. As the title suggests, we’ll be building a force graph to navigate through a website built with Next.js. By using a force graph for navigation, we create a distinctive and visually appealing method for users to interact with a website.
Furthermore, it can aide users in comprehending the relationships between different pages. Each node represents the pages of a website, allowing users to interact with the nodes corresponding to distinct pages. As you’ve probably noticed, this is the same UI used on this website.
Technologies
Next.js
For this tutorial we’re using the App Router. Adapting the code for Pages Router should be straightforward since we're only working with Client Components. Familiarity with the useEffect, and usePathname hooks are essential.
D3.js
A basic understanding of D3.js for selecting React-rendered elements and data binding is required. If you're new, check out the D3.js documentation and React and D3 by Amelia Wattenberger. Also, here's a D3-force example.
Finally here’s a repository to code alongside with, along with a live demo.. Happy coding!
Step 1: Project Setup
Clone the repository and checkout to the tutorial-start
branch. After installing dependencies with npm i
, run npm run dev
to run a development server locally. Open /src/app/_components/ForceGraph.js
. You'll see that we have a starting point for our component.
"use client";
import { useRouter } from "next/navigation";
export default function ForceGraph({ width = 250, height = 250 }) {
const router = useRouter();
const handleRoute = (node) => {
router.push(node.route);
};
return (
<svg
className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
></svg>
);
}
Writing the Data
In order for D3-force to create the force graph, we need to give it the right shape of data. D3-force requires an array of nodes. It also requires an array of links, which describe the relationship between the nodes.
Nodes
In our case, the nodes will represent the pages on our application. Each node is an object with an id
and route
properties. You’ll notice that we already have the nodes data hard coded in /src/data/nodes.json
, as, our navbar makes use of that data.
const nodes = [
{
id: "home",
route: "/",
},
{
id: "work",
route: "/work",
},
{
id: "blog",
route: "/blog",
},
// additional nodes...
];
Links
Links describe relationships between nodes, which are defined by source
and target
properties. It's important that the values inside our source
and target
properties coincide with existing id's in the nodes. This is necessary for D3-force to create the force graph.
const links = [
{
// node with id of home links to node with id of work
source: "home",
target: "work",
},
{
// node with id of home links to node with id of blog
source: "home",
target: "blog",
},
// additional links...
];
Now that we understand the data, let’s hard code the links. Go to /src/data
and create links.json.
Next, copy and past the following the data into the file. You’ll notice that we’ve added a property called id
. This will be passed into the key prop when we render the links with React.
[
{
"source": "home",
"target": "work",
"id": 0
},
{
"source": "home",
"target": "blog",
"id": 1
},
{
"source": "home",
"target": "info",
"id": 2
},
{
"source": "home",
"target": "contact",
"id": 3
}
]
As mentioned earlier, we already have the nodes data as it’s being used in our navbar. This structured data is crucial for D3-force to generate the force graph accurately. Now that we have the data, we can leverage React to render it.
Step 2: Render the Data with React
For our purposes, we'll leverage React to render the nodes, links, and labels. After they're rendered, we'll use D3.js to style them.
Create Client Components for rendering the nodes (Circles.js)
, links (Lines.js)
, and labels (Labels.js)
.
"use client";
export default function Circles({ nodes, handleRoute }) {
return nodes.map((node) => (
<circle key={node.id} onClick={() => handleRoute(node)} />
));
}
The handleRoute
prop that allows us to route to the page represented by the node when it is clicked on.
"use client";
export default function Lines({ links }) {
return links.map((link) => <line key={link.id} />);
}
"use client";
export default function Labels({ nodes }) {
return nodes.map((node) => (
<text alignmentBaseline="middle" key={node.id}>
{node.id}
</text>
));
}
Render these components in the <svg>
tags inside ForceGraph.js
, import the nodes and links, and pass the data and handleRoute
function into the corresponding props.
"use client";
import { useRouter } from "next/navigation";
import nodes from "@/data/nodes";
import links from "@/data/links";
import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";
export default function ForceGraph({ width = 250, height = 250 }) {
const router = useRouter();
const handleRoute = (node) => {
router.push(node.route);
};
return (
<svg
className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
>
<Lines links={links} />
<Circles nodes={nodes} handleRoute={handleRoute} />
<Labels nodes={nodes} />
</svg>
);
}
We're now using React to render the nodes, links, and labels. We can now leverage D3.js to simulate the force graph.
Step 3: Initialize Simulation
With D3-force, we’re essentially using a physics engine that positions our SVG elements depending on the forces acting on the simulation. In order to do this, we first need to initialize the simulation.
Go to /src/data/
and create a file called graph.config.json
. We'll declare a set of constants for simulation configuration. This allows us to configure the forces and appearance of our components in one place.
{
"LINK_DISTANCE": 70,
"LINK_STRENGTH": 1.5,
"MANY_BODY_FORCE": -100,
"X_FORCE": 0.05,
"Y_FORCE": 0.05,
"DEFAULT_NODE_RADIUS": 8,
"CURRENT_NODE_RADIUS": 9,
"HOVER_NODE_RADIUS": 10,
"LINK_STROKE_WIDTH": 2,
"LABEL_X": 10,
"TRANSITION_LENGTH": 100
}
Next, navigate to src/utils/
and create a file called simulationHelpers.js.
In this file, declare a function named initializeGraph
. This function initializes the forces that will act on the nodes and links. It will accept a parameter called simulation
. This parameter will be a variable provided to us by a useRef
hook. Here are the reasons why it's best to use a useRef
hook to store the simulation:
- The simulation will need to be mutated as we use the force graph and update it's values. This is the nature of D3.js.
useRef
doesn't trigger our component to rerender whenever the value changes.- The value of
simulation
will persist across rerenders and need to be accessed by other functions inside ourForceGraph.js
component.
import GRAPH_CONFIG from '@/data/graph.config';
import * as d3 from "d3";
function initializeGraph(simulation, d3) {
const {
LINK_DISTANCE,
LINK_STRENGTH,
MANY_BODY_FORCE,
X_FORCE,
Y_FORCE
} = GRAPH_CONFIG;
// simulation is provided to us by a useRef hook, cence the .current property
simulation.current = d3
.forceSimulation()
.force("link", d3
.forceLink()
.id((link) => link.id)
.distance(LINK_DISTANCE)
.strength(LINK_STRENGTH)
)
.force("charge", d3
.forceManyBody()
.strength(MANY_BODY_FORCE)
)
.force("x", d3
.forceX()
.strength(X_FORCE)
)
.force("y", d3
.forceY()
.strength(Y_FORCE)
)
.alphaTarget(0);
}
export { initializeGraph };
Call initializeGraph
in a useEffect
inside ForceGraph.js
. Don’t forget to create a simulation
variable with useRef
and pass it into initialzeGraph
. We only need to initialize the simulation when the component mounts, so we’ll have an empty dependency array.
"use client";
import { useRouter } from "next/navigation";
import { useRef, useEffect } from "react";
import nodes from "@/data/nodes";
import links from "@/data/links";
import { initializeGraph } from "@/utils/simulationHelpers";
import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";
export default function ForceGraph({ width = 250, height = 250 }) {
const router = useRouter();
const simulation = useRef();
const handleRoute = (route) => {
router.push(route);
};
useEffect(() => {
initializeGraph(simulation);
}, []);
return (
<svg
className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
>
<Lines links={links} />
<Circles nodes={nodes} handleRoute={handleRoute} />
<Labels nodes={nodes} />
</svg>
);
}
Step 4: Bind Simulation Data to SVG Elements
Now that we have the simulation initialized, we can bind the data created by the simulation to the SVG elements rendered by React. We’ll be able to update the graph whenever the user routes to a new page.
Declare an updateGraph
function in simulationHelpers.js
. It will take four parameters: simulation
, nodes
, links
, and currentPath
(provided by the [usePathname](https://nextjs.org/docs/app/api-reference/functions/use-pathname)
hook).
function updateGraph(simulation, nodes, links, currentPath) {
const {
CURRENT_NODE_RADIUS,
DEFAULT_NODE_RADIUS,
LINK_STROKE_WIDTH,
LABEL_X,
} = GRAPH_CONFIG;
// passing in nodes and links into the simulation instance
simulation.current.nodes(nodes);
simulation.current.force("link").links(links);
simulation.current.alpha(1).restart();
// selecting SVG elements and binding data with .data()
const nodeSelection = d3.selectAll("circle").data(nodes);
const linkSelection = d3.selectAll("line").data(links);
const labelSelection = d3.selectAll("text").data(nodes);
// styling our selections
nodeSelection
.attr("fill", (node) => (node.route === currentPath ? "#333" : "#e1e1e1"))
.attr("r", (node) =>
node.route === currentPath ? CURRENT_NODE_RADIUS : DEFAULT_NODE_RADIUS
);
linkSelection
.attr("stroke", "#e1e1e1")
.attr("stroke-width", LINK_STROKE_WIDTH);
labelSelection.attr("fill", "#000").style("font-size", ".75em");
// "tick" event that updates positions of nodes and links as the simulation iterates
simulation.current.on("tick", (d) => {
nodeSelection.attr("cx", (node) => node.x).attr("cy", (node) => node.y);
linkSelection
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
labelSelection
.attr("x", (node) => node.x + LABEL_X)
.attr("y", (node) => node.y);
});
}
export {
initializeGraph,
updateGraph
};
The code in updateGraph
is basic DOM selection and data binding using D3, as well as handling a “tick” event in the simulation. This allows us update the position of the nodes, links and labels when the simulation runs.
Call updateGraph
in a useEffect
inside ForceGraph.js
. The useEffect
will have currentPath
as a dependency, since we want to update our graph whenever we navigate to a different page.
"use client";
import { useRouter, usePathname } from "next/navigation";
import { useRef, useEffect } from "react";
import nodes from "@/data/nodes";
import links from "@/data/links";
import { initializeGraph, updateGraph } from "@/utils/simulationHelpers";
import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";
export default function ForceGraph({ width = 250, height = 250 }) {
const router = useRouter();
const simulation = useRef();
const currentPath = usePathname();
const handleRoute = (route) => {
router.push(route);
};
useEffect(() => {
initializeGraph(simulation);
}, []);
useEffect(() => {
updateGraph(simulation, nodes, links, currentPath);
}, [currentPath]);
return (
<svg
className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
>
<Lines links={links} />
<Circles nodes={nodes} handleRoute={handleRoute} />
<Labels nodes={nodes} />
</svg>
);
}
At this point, you'll be able see and click on the graph to route to pages.
Step 5: Add D3 Event Handlers
Finally, we'll create drag and hover handlers.
In simulationHelpers.js
Declare a a function called addD3EventHandlers
. It will take two parameters: simulation
, and currentPath
.
function addD3EventHandlers(simulation, currentPath) {
const {
CURRENT_NODE_RADIUS,
DEFAULT_NODE_RADIUS,
HOVER_NODE_RADIUS,
TRANSITION_LENGTH,
} = GRAPH_CONFIG;
// selecting SVG elements
const nodeSelection = d3.selectAll("circle");
const labelSelection = d3.selectAll("text");
// drag event
nodeSelection.call(
d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended)
);
function dragstarted(event) {
if (!event.active) simulation.current.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.current.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
// mouse over event and styling logic
nodeSelection.on("mouseover", (event, currentNode) => {
d3.select(event.target).style("cursor", "pointer");
nodeSelection
.transition(TRANSITION_LENGTH)
.attr("fill", (node) => (node.id === currentNode.id ? "#333" : "#e1e1e1"))
.attr("r", (node) =>
node.id === currentNode.id ? HOVER_NODE_RADIUS : DEFAULT_NODE_RADIUS
);
labelSelection
.transition(TRANSITION_LENGTH)
.style("font-size", (node) =>
currentNode.id === node.id ? ".9em" : ".75em"
);
});
// mouse out event and styling logic
nodeSelection.on("mouseout", (event) => {
d3.select(event.target).style("cursor", "pointer");
nodeSelection
.transition()
.attr("fill", (node) => (node.route === currentPath ? "#333" : "#e1e1e1"))
.attr("r", (node) =>
node.route === currentPath ? CURRENT_NODE_RADIUS : DEFAULT_NODE_RADIUS
);
labelSelection
.transition()
.attr("fill", "#000")
.style("font-size", ".75em");
});
}
export {
initializeGraph,
updateGraph,
addD3EventHandlers
};
Call the addD3EventHandlers
in a useEffect
inside ForceGraph.js
. The useEffect
will have currentPath
as a dependency, since we want to update our event handlers whenever we navigate to a different page.
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useRef, useEffect } from "react";
import nodes from "@/data/nodes";
import links from "@/data/links";
import { initializeGraph, updateGraph, addD3EventHandlers } from "@/utils/simulationHelpers";
import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";
export default function ForceGraph({ width = 250, height = 250 }) {
const currentPath = usePathname();
const simulation = useRef();
const router = useRouter();
const handleRoute = (route) => {
router.push(route);
}
useEffect(() => {
initializeGraph(simulation)
}, []);
useEffect(() => {
updateGraph(simulation, nodes, links, currentPath)
}, [currentPath]);
useEffect(() => {
addD3EventHandlers(simulation, currentPath)
}, [currentPath]);
return (
<svg
className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
>
<Lines links={links}/>
<Circles nodes={nodes} handleRoute={handleRoute}/>
<Labels nodes={nodes}/>
</svg>
);
}
Congratulations! You now have a functioning force graph to route through your website.
Conclusion and Final Notes
In conclusion, employing a force graph for website navigation not only establishes a unique and visually captivating interaction method for users, but also assists them in grasping the interconnected relationships among various pages. This tutorial serves a solid foundation for any applications you may have.
As cool as this UI is, I wouldn’t recommend this as the primary form of navigation due to SEO considerations. Next.js Link components are crucial for SEO, and they automatically prefetch routes for your website's client side routing.
Feel free to check my GitHub, Twitter, and LinkedIn. Any improvements? Open a pull request on my repository. Thank you for reading through my first ever blog post!