Inspired by the video game Split Fiction, I'm amazed by the creators' imagination in showcasing two distinct worlds side by side through a split screen. Motivated by their creativity, I am eager to recreate this split effect right within the browser.
TL;DR
Basic HTML & CSS
The easiest part of this effect is to create a static split screen in the browser.
The skeleton:
class="container" id="container">
class="side left-side" id="leftSide">
class="side right-side" id="rightSide">
Some basic CSS reset:
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
user-select: none;
cursor: crosshair;
background: #222;
}
Let the container cover the full screen:
.container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
Both sides should cover the entire screen, utilizing clip-path to control which area is displayed on each side.
.side {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.left-side {
background-color: rgb(150, 72, 222);
z-index: 1;
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
}
.right-side {
background-color: rgb(55, 234, 136);
z-index: 1;
clip-path: polygon(50% 0, 100% 0, 100% 100%, 50% 100%);
}
You got:
Add background image for both sides:
.left-side {
background-image: url(https://static0.gamerantimages.com/wordpress/wp-content/uploads/2024/12/split-fiction-splitscreen-platforming.jpg?q=49&fit=crop&w=750&h=422&dpr=2);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 1;
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
}
.right-side {
background-image: url(https://static0.gamerantimages.com/wordpress/wp-content/uploads/2024/12/split-fiction-fantasy-transformations.jpg?q=49&fit=crop&w=750&h=422&dpr=2);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 1;
clip-path: polygon(50% 0, 100% 0, 100% 100%, 50% 100%);
}
You got background image on both sides:
Rotate the Split Line with JavaScript
Our target is to update the clip-path
along with mouse moving. Here's the brain-teaser time!🤯
For each side, we need four points to draw the polygon.
First thing we need to do is get intersections of screen edges and the split line.
Some preparation work, I need the coordinates of the center point, the angle between the center and the mouse, ...
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const centerX = containerWidth / 2;
const centerY = containerHeight / 2;
let deltaX = mouseX - centerX;
let deltaY = mouseY - centerY;
if (deltaX === 0 && deltaY === 0) {
deltaX = 0.0001; // Avoid zero vector
}
// Calculate the angle between the center and the mouse position
const angleRadians = Math.atan2(deltaY, deltaX);
// Direction vector of the split line (unit vector)
const lineDirX = Math.cos(angleRadians);
const lineDirY = Math.sin(angleRadians);
// Define the edges of the viewport
const viewportEdges = [
[{ x: 0, y: 0 }, { x: containerWidth, y: 0 }], // Top edge
[{ x: containerWidth, y: 0 }, { x: containerWidth, y: containerHeight }], // Right edge
[{ x: containerWidth, y: containerHeight }, { x: 0, y: containerHeight }], // Bottom edge
[{ x: 0, y: containerHeight }, { x: 0, y: 0 }] // Left edge
];
// Find intersections of the split line with the viewport edges
let intersectionPoints = [];
for (const edge of viewportEdges) {
const intersection = lineSegmentIntersection(centerX, centerY, lineDirX, lineDirY, edge[0], edge[1]);
if (intersection) intersectionPoints.push(intersection);
}
And, this is the function to calculate whether the split line is intersect with the edge. If true, it will return the coordinates of the intersection point.
/**
* Calculates the intersection point between an infinite line and a line segment.
* @param {number} linePointX - X coordinate of a point on the infinite line.
* @param {number} linePointY - Y coordinate of a point on the infinite line.
* @param {number} lineDirX - X component of the infinite line's direction vector.
* @param {number} lineDirY - Y component of the infinite line's direction vector.
* @param {Object} segmentStart - Starting point of the line segment {x, y}.
* @param {Object} segmentEnd - Ending point of the line segment {x, y}.
* @returns {Object|null} - Intersection point {x, y} or null if no intersection exists.
*/
function lineSegmentIntersection(linePointX, linePointY, lineDirX, lineDirY, segmentStart, segmentEnd) {
// Direction vector of the line segment
const segmentDirX = segmentEnd.x - segmentStart.x;
const segmentDirY = segmentEnd.y - segmentStart.y;
// Calculate the determinant (cross product of direction vectors)
const determinant = lineDirX * segmentDirY - lineDirY * segmentDirX;
// If determinant is 0, the line and segment are parallel or collinear
if (determinant === 0) return null;
// Calculate parameters t and u
const t = ((segmentStart.x - linePointX) * segmentDirY - (segmentStart.y - linePointY) * segmentDirX) / determinant;
const u = ((segmentStart.x - linePointX) * lineDirY - (segmentStart.y - linePointY) * lineDirX) / determinant;
// If u is not in the range [0, 1], the intersection point is outside the segment
if (u < 0 || u > 1) return null;
// Calculate the intersection point
return {
x: linePointX + t * lineDirX,
y: linePointY + t * lineDirY
};
}
Now, we need to determine whether corners belong to the left side or the right side.
The code:
// Normal vector: perpendicular to the direction vector
const normalX = lineDirY;
const normalY = -lineDirX;
// Define the corners of the viewport
const viewportCorners = [
{ x: 0, y: 0 },
{ x: containerWidth, y: 0 },
{ x: containerWidth, y: containerHeight },
{ x: 0, y: containerHeight }
];
// Separate corners into left and right groups based on the signed distance
let leftSidePoints = [];
let rightSidePoints = [];
for (const corner of viewportCorners) {
const distance = signedDistance(corner.x, corner.y, centerX, centerY, normalX, normalY);
if (distance < 0) {
leftSidePoints.push(corner);
} else {
rightSidePoints.push(corner);
}
}
// Add intersection points to both sides
leftSidePoints.push(...intersectionPoints);
rightSidePoints.push(...intersectionPoints);
Use signedDistance
to determine the corner is on which side of the split line.
/**
* Calculates the signed distance from a point to a line defined by a normal vector.
* The sign of the distance indicates which side of the line the point lies on.
*
* @param {number} px - X coordinate of the point.
* @param {number} py - Y coordinate of the point.
* @param {number} cx - X coordinate of a point on the line (e.g., the center point).
* @param {number} cy - Y coordinate of a point on the line.
* @param {number} nx - X component of the line's normal vector.
* @param {number} ny - Y component of the line's normal vector.
* @returns {number} - The signed distance from the point to the line.
*/
function signedDistance(px, py, cx, cy, nx, ny) {
// Calculate the vector from the line's reference point (cx, cy) to the target point (px, py).
const vectorX = px - cx;
const vectorY = py - cy;
// Compute the dot product of the vector and the normal vector of the line.
// This projects the vector onto the normal vector, giving the signed distance.
return vectorX * nx + vectorY * ny;
}
The coordinates we require are stored in the leftSidePoints
and rightSidePoints
. Reorder those coordinates in clock wise before do the final step.
/**
* Orders a set of points in clockwise order around their centroid.
* This is useful for defining polygons where the order of points matters.
*
* @param {Array} points - An array of points, each with {x, y} properties.
*/
function orderPointsClockwise(points) {
// Step 1: Calculate the centroid (geometric center) of the points.
// The centroid is the average of all x and y coordinates.
const centroid = points.reduce((acc, p) => ({
x: acc.x + p.x,
y: acc.y + p.y
}), { x: 0, y: 0 });
centroid.x /= points.length;
centroid.y /= points.length;
// Step 2: Sort the points based on their angle relative to the centroid.
// Use Math.atan2 to calculate the angle between each point and the centroid.
points.sort((a, b) => {
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x); // Angle of point A
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x); // Angle of point B
return angleA - angleB; // Sort in ascending order of angle
});
}
// Order points clockwise for clip-path polygons
orderPointsClockwise(leftSidePoints);
orderPointsClockwise(rightSidePoints);
Let's convert the coordinates to the clip-path
properties.
// Convert points to clip-path format
const pointsToClipPath = (points) => points.map(point => `${point.x}px ${point.y}px`).join(', ');
// Update the clip-path for both sides
leftSide.style.clipPath = `polygon(${pointsToClipPath(leftSidePoints)})`;
rightSide.style.clipPath = `polygon(${pointsToClipPath(rightSidePoints)})`;
Listen to the Mouse Move
The split line needs to move along with the mouse.
// Initialize with vertical split on page load
updateSplit(window.innerWidth / 2, window.innerHeight / 2);
// Update on mouse enter and move
container.addEventListener('mouseenter', e => {
updateSplit(e.clientX, e.clientY);
});
container.addEventListener('mousemove', e => {
updateSplit(e.clientX, e.clientY);
});
// Reset on mouse leave
container.addEventListener('mouseleave', () => {
leftSide.style.clipPath = 'polygon(0 0, 50% 0, 50% 100%, 0 100%)';
rightSide.style.clipPath = 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)';
});
Demo
Or access the code on GitHub.