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

Code | Demo

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:

basic split screen

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:

Image description

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.

Image description

Image description

First thing we need to do is get intersections of screen edges and the split line.

Image description

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.

Image description

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.