<tilt-elemental>
The 3D tilt every product page has: a card that leans away from the pointer, with a highlight travelling across it and whichever parts you name standing out of the surface. You write the card; it wraps it. Light DOM, no shadow root, nothing you wrote is moved or wrapped.
There is no APG pattern here, because there is no widget β nothing is operated, and the
content is the same content lying flat. What there is instead is an obligation, and it is one
nothing else in this corner of the ecosystem meets.
WCAG 2.2 2.3.3 Animation from Interactions
says motion animation triggered by interaction has to be able to be turned off, unless the
animation is essential. A decorative tilt is never essential β it conveys nothing β so there
is no exemption to claim, and prefers-reduced-motion is the technique the criterion itself
names.
| The library | What it costs you | What this does |
|---|---|---|
| vanilla-tilt.js | twenty-odd documented options, none of them motion; a search of its issues for "reduced motion" returns nothing | reads the setting, follows it live, and never attaches a listener |
| tilt.js | jQuery, and the same silence on reduced motion | one file, one dependency, and that one is the sibling spellbook |
| Atropos | four nested <div>s β .atropos, .atropos-scale, .atropos-rotate, .atropos-inner β before anything tilts |
your markup, unchanged, inside one wrapper |
| all of them | a lerp inside requestAnimationFrame, running as long as the pointer is over the card |
a CSS transition, eased by the compositor, which stops on its own |
<tilt-elemental glare max="12">
<article class="card">
<p class="kicker" data-tilt-depth="15">Elemental</p>
<h3 data-tilt-depth="40">Tilt</h3>
<p data-tilt-depth="10">A card that leans away from the pointer, and lies back down when it leaves.</p>
</article>
</tilt-elemental>
/* the lean, the glare and the layers are the element's; the card is yours. Nothing here is
needed to make it tilt β it is what makes three paragraphs look like a card */
tilt-elemental { max-width: 22rem; }
tilt-elemental .card {
padding: 2rem;
background: linear-gradient(135deg, color-mix(in srgb, CanvasText 12%, Canvas), Canvas);
border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
/* the theme's radius, taken rather than repeated β and `border-radius` rather than
`overflow: hidden`, which would flatten every layer above */
border-radius: inherit;
}
tilt-elemental .kicker {
margin: 0 0 0.25rem;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
}
tilt-elemental h3 { margin: 0 0 0.5rem; }
tilt-elemental p:last-child { margin: 0; }Move across it and the edge you are pointing at goes away from you, the highlight follows the cursor, and the heading stands further out of the card than the paragraph under it. Run the cursor along the very edge: it holds its lean instead of flickering. Leave, and the card settles flat over a longer beat than it took to lean while the highlight fades out where it stood. Turn on "reduce motion" in your system settings and reload: nothing moves at all.
The markup
One wrapper round the card you were writing anyway:
<tilt-elemental>
<article class="card">
<img src="/photo.jpg" alt="">
<h3>A heading</h3>
</article>
</tilt-elemental>
- Whatever is inside is the card. The element writes no roles, no ARIA and no names β everything a screen reader hears is what you wrote, in the order you wrote it.
- The wrapper is
display: blockand nothing else, so it is transparent to your layout. Give it a width, put it in a grid, do what you would have done to the card itself. - The card's own background belongs on your element, not on the wrapper. The wrapper is the thing that leans; the card is the thing that is drawn.
Layers
Mark any descendant data-tilt-depth and it rises out of the surface while the card is
leaning, then settles back with it:
<tilt-elemental>
<article class="card">
<img src="/photo.jpg" alt="">
<h3 data-tilt-depth="40">Standing out of it</h3>
<p data-tilt-depth="15">Not as far</p>
</article>
</tilt-elemental>
The number is a count of --tilt-elemental-depth-step, which is 1px β so 40 is 40 pixels
towards the reader. Negative sinks it in. Any depth on any descendant, at any nesting, and
they compose the way transforms do.
They rise only while the card is leaning. A layer floating permanently above a resting card reads as a heading slightly too large for its box, which is a bug report about the font.
A layer works at any depth of nesting, and that is not free. A layer only rises if every
box between it and the card is in the same 3D space, and transform-style defaults to flat β
so the one plain <article> most people wrap their content in flattens every depth inside it
straight back onto the surface. Nothing warns: the transform still computes, still reads
translateZ(40px) in the inspector, and is simply drawn with no depth. The stylesheet puts
preserve-3d on every box that contains a layer, so the wrapper you wrote is not something you
have to know about.
overflow is the trap that is left, and it is not this element's to fix. Per
the spec, these force
transform-style back to flat β on the element or on any wrapper between it and a layer:
| Property | Value that flattens |
|---|---|
overflow |
anything but visible or clip |
filter, mask-image |
anything but none |
opacity |
below 1 |
clip-path, mix-blend-mode |
anything but none / normal |
isolation |
isolate |
contain |
paint, and anything implying paint containment |
The usual reflex for a rounded card is overflow: hidden, and that is exactly the one that
costs you the layers. Round the card with border-radius alone β which is what the optional
theme does β and note that overflow: clip is not on the list, so it is the one that clips
without flattening.
A layer added after the element upgraded is not seen: the depth is copied out of the attribute
into a custom property once, because CSS cannot read an attribute as a number. Render new
layers and call .update().
The glare
glare draws a highlight that follows the pointer across the card. It is in the element's own
stylesheet rather than in the optional theme, which is the one place the line between those two
files bends: the attribute is a promise, and a promise kept only for whoever also imported the
look is a promise broken for everyone else.
<tilt-elemental glare>β¦</tilt-elemental>
It is one colour, alpha included β --tilt-elemental-glare-color β rather than a colour and
an opacity, because a glare is the two together and nobody sets one without the other. The
default is two weights of the same white, light-dark(rgb(255 255 255 / 100%), rgb(255 255 255 / 10%)),
because the surface decides how much of a white light shows: 10% over a dark card is a sheen,
and over a light card nothing short of full white reads at all β 80% was still invisible on
this page's own demo. Full white is also the ceiling: a white glare cannot show on a pure
white surface, so on a light card the highlight lives in whatever shading the card has, and
a card that wants more of it wants a tinted glare. light-dark() follows the page's
color-scheme, so a themed site's toggle carries the glare with it; a page that never
declares one resolves to the light value. --tilt-elemental-glare-size is how far it spreads
before it is gone: small is a hotspot, large is a wash.
The glare sits on the card's own surface, so a layer with a depth rises through it and is lit from below rather than over the top. That is the right way round for a title standing out of a photograph, and it is worth knowing before wondering where the highlight went.
It fades out where it stood. When the pointer leaves, the card straightens but the highlight's position is left exactly where the pointer last was β only its opacity goes. Put the position back to the middle along with everything else and the light travels to a place the pointer never was, on its way out, which is the one movement in this element nobody asked for.
Why it does not flicker at its own edge
Every tilt card has a stutter along its border, and it is worth saying where it comes from because the fix is the reason this element does not need Atropos's wrapper divs.
The card leans away from the pointer, so the edge the pointer is nearest is always the edge that swings back β and a receding edge projects inwards. Hit-test against the leaning card and a pointer a pixel inside that edge falls outside it: the leave fires, the card straightens, the edge lands back under the pointer, and it leans again. Sixty times a second, taking the shadow with it.
The box that decides is the box the card has when it is flat, read at the first pointer event of a hover β the one moment it can be read β and kept for the length of that hover. The angles are measured against it, and so is the question of whether the pointer has left, which is answered from coordinates rather than from whether the event reached the element.
| The other fixes | What they cost |
|---|---|
| Atropos: three wrapper divs so the hit-tested box never moves | markup you did not write, and cannot restructure |
vanilla-tilt: a mouse-event-element option pointing at some other element |
you have to know the problem exists, and find an element |
| lifting the card towards the reader so it never shrinks | does not work: what the rotation gives away grows with the square of the card's size, where a lift only scales it. Measured, 2.5rem of lift on a 640Γ400 card at 10Β° was still nine pixels short |
Two consequences worth knowing. While the pointer is over a card, the element listens on the document β that is what sees the pointer leave from the outside rather than waiting to be told. And a scroll straightens the card: the cached box has moved and cannot be re-measured while the card is leaning, so the honest answer is to lie flat and take a fresh measurement on the next pointer event.
Which way it leans
Away from the pointer β the feel of pressing a corner, rather than of the card following a
magnet. reverse is the other one:
| Pointer at the top | Pointer at the right | |
|---|---|---|
| default | the top edge goes away | the right edge goes away |
reverse |
the top edge comes towards you | the right edge comes towards you |
The glare does not flip with it. Reverse changes which way the card leans; the light stays where the reader is pointing.
axis keeps one rotation and drops the other:
axis |
What is left |
|---|---|
| absent | both |
x |
the card nodding, as the pointer moves up and down |
y |
the card turning, as the pointer moves across |
vanilla-tilt spells the same word the other way round β its axis: "x" disables the x
rotation. Ours names what survives.
Motion, and who gets it
prefers-reduced-motion is read and then followed. With the setting on, no pointer handler
is attached at all: no transform, no glare, no layers rising. It is followed live rather than
read once at upgrade β unlike <marquee-elemental>, which reads it a single time because it
writes a Start button and a reader who pressed it must not be overruled later. There is no
control here, so there is nothing to overrule.
There is no @media (prefers-reduced-motion: reduce) rule in the stylesheet doing the same job
either. There is nothing for it to suppress, and a second place the same fact is written is two
things to keep in step.
The pointer is a mouse or it is nothing. A finger dragging across a card is a page trying to
scroll, and a card that tilts under it is a card fighting the gesture it intercepted.
vanilla-tilt and Atropos both offer touch and gyroscope modes; this has neither, and the
gyroscope one is a decision rather than an omission β
DeviceOrientationEvent.requestPermission()
needs transient activation and a permission prompt on iOS, and a card that asks for sensor
access in order to wobble is a worse trade than a card that does not wobble on a phone.
There is no keyboard equivalent, and that is deliberate. Motion a reader cannot avoid triggering is the thing 2.3.3 is about; a tilt that fired on focus would put it in front of exactly the people who did not reach for it.
Without script
A <div> that does nothing. Every rule but display: block is behind :defined, so with no
JavaScript there is no perspective, no transform and no glare β the card is your card, at your
size, in reading order. display is the one that cannot wait: an unupgraded inline wrapper
round a block card is a layout nobody wrote.
Attributes
| Attribute | Type | Default | What it does |
|---|---|---|---|
max |
number | 10 |
Degrees at the edge of the box. Anything that is not a number at or above zero is the default. |
axis |
x, y |
both | Keep one rotation, drop the other. An unrecognised value is both, rather than a card that silently stopped moving because of a typo. |
reverse |
boolean | off | Lean towards the pointer instead of away. |
glare |
boolean | off | Draw the highlight. |
max="0" is a card that does not tilt, and the glare still follows the pointer. It is the
one number here that cannot fall back on being falsy, because zero is a value an author means.
10 is a chosen number, not a derived one. GitHub's own card β the one this element was
extracted from β leans 2 degrees, which is so slight that most people never notice it is there;
ten is where the lean reads as deliberate without the card looking like it has fallen over.
Styling
The structure stylesheet leans the card, raises the layers and draws the glare. The theme adds the corners and a shadow that moves with the lean β barely there while the card lies flat, full weight while it leans.
| Custom property | Default | What it does |
|---|---|---|
--tilt-elemental-perspective |
1000px |
How near the reader is. Smaller is a wider lens and a stronger lean for the same angle |
--tilt-elemental-duration |
120ms |
How long the card takes to catch up with the pointer β the trailing feel every tilt library calls speed |
--tilt-elemental-return-duration |
400ms |
How long it takes to settle flat once the pointer has gone |
--tilt-elemental-easing |
ease-out |
How both of those move |
--tilt-elemental-depth-step |
1px |
What one unit of data-tilt-depth is worth |
--tilt-elemental-glare-color |
white light, white at 10% dark | The highlight, alpha included |
--tilt-elemental-glare-size |
60% |
How far it spreads before it is gone |
--tilt-elemental-radius |
0.75rem |
Theme. The card's corners, which the glare follows |
--tilt-elemental-shadow-size |
1.5rem |
Theme. How soft the shadow is |
--tilt-elemental-shadow-color |
currentcolor at 30% |
Theme. Its colour, and the fill of the layer it is drawn on |
Four more are written by the element and are there to read, not to set:
--tilt-elemental-x and --tilt-elemental-y are the two angles, --tilt-elemental-glare-x
and --tilt-elemental-glare-y are where the pointer is across the box. All four are
unitless, and the stylesheet puts the unit back on β which is what lets the theme spend the
same --tilt-elemental-y as a pixel translation for the shadow while the transform spends it
as a degree. One pair of numbers, two units, no dividing a deg back out of anything.
Both pseudo-elements are spoken for, and that is the element's whole budget: ::after is
the glare, ::before is the theme's shadow.
The shadow is the card's shape, filled and blurred β which is what a shadow is, and is not
what box-shadow gives you here. Two things go wrong with one:
With box-shadow on the layer |
What you see |
|---|---|
| it never paints inside its own box | the layer slides out from behind the leaning card and what emerges is the hole in the middle of the ring β at twenty degrees, a thin outline and nothing else |
| it holds full colour to the box edge and fades only outward | fill the hole and the two emerging sides have a hard rim, which reads as a sticker rather than a shadow |
Blurring the fill fades it across the edge in both directions, so there is no rim. The fill is not optional either way: blurring an empty box blurs nothing.
At rest the layer sits at a quarter of its opacity and fades up to full as the card starts to
lean: a flat card casts almost nothing, and the shadow arriving with the lean is part of what
sells the lift. --tilt-elemental-shadow-color is still the one colour knob β both states
follow it.
--tilt-elemental-shadow-size is spent as half of itself, because box-shadow's blur radius
is defined as twice the standard deviation of the gaussian it approximates and filter: blur()
takes that deviation directly β so the same number means the same softness it always did. The
colour default went from 20% to 30% for the same reason: a blurred shape is only half-opaque at
its own edge, so the same weight on the page needs more of it spent on the soft part.
The cost, plainly: this is a coloured slab rather than a hole, so a card with a see-through
background has its shadow visible through it β point --tilt-elemental-shadow-color at
transparent on one of those.
filter is on the flattening list above, and that is exactly why it is on the shadow layer
rather than on the card: it flattens the element it sits on, and that element has no children
to flatten. Measured β the card still leans, every data-tilt-depth still rises, and the paint
count is unchanged.
The shadow being a pseudo at all is the difference between a shadow that follows the lean and
one that stutters while doing it. box-shadow is a paint property: give its offset a custom
property that changes every frame and the browser repaints a soft blur sixty times a second, on
the main thread, while the card beside it glides because a transform is the compositor's job.
The two run at different rates and you can see it. Drawn once on a layer that is then only
translated, the numbers are identical and the paint work is not:
| Over one 240-frame hover | Paint events | Raster tasks |
|---|---|---|
offset inside box-shadow |
478 | 302 |
| translated pseudo-element (what ships) | 9 | 0 |
β¦with glare on as well |
136 | 63 |
That last row is honest rather than incidental: the glare's gradient centre is a custom
property, so the gradient itself repaints as the pointer moves. The same fix β a fixed gradient
on an oversized layer that only translates β needs overflow: clip on the card to hide the
overhang, which would clip the shadow's own blur away with it. The two cannot both be
composited without a third box, and a third box is the wrapper this element exists not to
write. The glare is the cheaper of the two to leave.
One attribute goes on the element as state, and it is yours to style against:
| Attribute | Means |
|---|---|
data-tilt-active |
the pointer is over the card and the four numbers above are written |
That is also the whole of the hover-scale feature this element does not have:
tilt-elemental[data-tilt-active] { scale: 1.03; }
Every custom property above is declared on the bare tilt-elemental selector rather than on
tilt-elemental:defined, so your own tilt-elemental { --tilt-elemental-perspective: 600px }
wins. A default hidden behind :defined is one class more specific than the rule you would
write to replace it, which is a default that quietly cannot be replaced.
What it will not do
No gyroscope, no touch, no keyboard trigger, no glare mask or blend mode, no scale of its own, and no opinion about what is on the card β a photograph is your image at your size, and a theme that put a surface colour and a padding under it would be a design system's answer written into a book of elements.
@use "book-of-elementals/tilt/style.scss";
@use "book-of-elementals/tilt/theme.scss"; // optional
import 'book-of-elementals/tilt';