Mapping the Mouse to CSS Custom Properties
Tracking the mouse position normally means reaching for JavaScript. But with CSS custom properties and a hoverable grid, we can map the cursor's location to values like --positionX and --positionY — no scripting required. This opens up effects that respond to the pointer, from resizing elements to shifting backgrounds, entirely in stylesheets.
The key is an invisible grid of cells layered over the content. Hovering over any cell lets us assign values to custom properties via the cascade, which we can then use with calc() to drive styles on other elements.
Building the Grid
Our starting point is a wrapper .content that fills the viewport and holds the elements we want to control (here, a .square). We lay an invisible grid of .cell elements on top:
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<!-- 97 more cells -->
<div class="content">
<div class="square"></div>
</div>
Two structural rules matter:
- Each
.cellmust precede the element it controls, since the cascade only lets an element affect its descendants or later siblings. - The
.cells cannot live in their own container — that would break the sibling relationship with.content.
A 10×10 grid gives us 100 cells total. The finer the grid, the smoother the position mapping, but more cells mean more DOM nodes and potential performance cost. Choose a density that balances accuracy against overhead.
Positioning Cells with Grid
The simplest way to lay out the cells is with CSS Grid:
body {
background-color: #000;
height: 100vh;
display: grid;
grid-template: repeat(10, 1fr) / repeat(10, 1fr);
}
.cell {
width: 100%;
height: 100%;
border: 1px solid gray;
z-index: 2;
}
Two details in that setup are worth noting: the border is only there temporarily to visualize the cells (we'll remove it later), and the z-index keeps the grid in front of .content so the cells receive the hover events.
Assigning Values Per Cell
The goal: hovering a cell in the first column sets --positionX to 0, the second column to 1, and so on. The same logic applies to rows and --positionY, with the top row starting at 0.Writing 100 individual rules would be tedious, but we can automate it with Sass and the general sibling combinator (~). Take cell 42 as an example — it sits in the third column and fourth row:
.cell:nth-child(42):hover ~ .content {
--positionX: 1;
--positionY: 3;
}
That works, but nobody wants to write it out for every cell. The cleaner approach combines a Sass @for loop with :nth-child() functional notation to target whole columns and rows at once. Three strategies exist:
- Loop through all 100 cells with Sass math.
- Select rows and columns independently using
:nth-child()notation. - Combine both.
Mixing the loop with functional notation gives the best balance of concise source code and efficient compiled CSS.
Columns and Rows via Functional Notation
We iterate 10 times to cover every row and column. For the x-axis, cells in column 2 are multiples of 10 plus 2 — which translates to :nth-child(10n + 2). The 10n selects every tenth element, and the + 2 picks the column offset. In the loop, we replace the column number with #{$i + 1}:
.cell:nth-child(10n + #{$i + 1}):hover ~ .content {
--positionX: #{$i};
}
The y-axis needs a different pattern because rows aren't periodic within a single :nth-child() expression. Row 4, for instance, contains cells 41 through 50. That range needs two selectors: :nth-child(n + 41) for the start and :nth-child(-n + 50) for the end. In the loop, those bounds become (n + #{10 * $i + 1}) and (-n + #{10 * ($i + 1)}):
@for $i from 0 to 10 {
.cell:nth-child(10n + #{$i + 1}):hover ~ .content {
--positionX: #{$i};
}
.cell:nth-child(n + #{10 * $i + 1}):nth-child(-n + #{10 * ($i + 1)}):hover ~ .content {
--positionY: #{$i};
}
}
With the loop in place, hovering over the grid updates --positionX and --positionY on .content. These properties can now drive any aspect of the elements inside it.
Putting the Values to Work
With the mouse position mapped, we can style the .square based on cursor movement. Say we want its width to start at 100px on the left edge and grow by 20px per column as the mouse moves right:
.square {
width: calc(100px + var(--positionX) * 20px);
}
Height works the same way, driven by --positionY:
.square {
width: calc(100px + var(--positionX) * 20px);
height: calc(100px + var(--positionY) * 20px);
}
That's the entire effect: as the cursor crosses the grid, the square's dimensions respond. A transition smooths the movement, though it's optional. Remove the cell borders once the layout is debugged.
An Alternative: Direct Values
If you don't need the intermediate --positionX/--positionY variables, you can compute the final values — like pixel sizes — directly inside the @for loop:
@for $i from 0 to 10 {
.cell:nth-child(10n + #{$i + 1}):hover ~ .content {
--squareWidth: #{100 + $i * 20}px;
}
.cell:nth-child(n + #{10 * $i + 1}):nth-child(-n + #{10 * ($i + 1)}):hover ~ .content {
--squareHeight: #{100 + $i * 20}px;: #{$i};
}
}
The controlled element then reads those precomputed custom properties:
.square {
width: var(--squareWidth);
height: var(--squareHeight);
}
This variant is more flexible if you want to exploit Sass's math or string functions during the loop. The principle stays the same, but the range of styles you can drive from the pointer position is broad: transform, background-position, colors, pseudo-element content, or the top/left offsets of absolutely positioned elements.



