# Rotating hero — per-image focal points

**Handoff note for porting the fix into the full Drupal site.**
Reference implementation: `artifacts/guilford-homepage-concepts/` (Concept G) —
`motion.js`, `motion.css`, `concept-g-relationship-first.html`.

---

## 1. The symptom

The homepage hero cross-fades several photos. On one of them — a student riding a
bicycle down the quad — **the top of her head is cut off**. The other photos in the
same rotation look fine.

## 2. The cause

The hero is a full-bleed CSS background using `cover`, with **one** focal point
shared by every photo in the rotation:

```css
.d-hero { background: url('…') center 42% / cover no-repeat; }
```

`cover` scales the image to fill the box and crops the overflow. `center 42%`
decides *where* that crop sits vertically. 42% was chosen for the photo the hero
originally shipped with, and it suits that composition.

It does not suit the others. Worked example, at 1440×900:

| | |
|---|---|
| Hero box | 1440 × **590** px |
| Photo | 1024 × 683 (3:2) |
| Scaled to cover width | 1440 × **960** px |
| Vertical overflow | 960 − 590 = **370 px** |
| Crop offset at 42% | 370 × 0.42 = **155 px** from the top |
| → first visible row, in original pixels | 155 / 960 × 683 = **110 px** |
| Subject's head, in original pixels | ≈ **100–190 px** |

The visible region starts at 110px; her head starts at 100px. The top 10px of her
hair is cropped away — and it gets worse as the hero gets shorter.

**The general rule:** a single focal point cannot serve multiple compositions. A
crop tuned to centre a building will behead a cyclist. Any rotating or
editorially-swappable hero needs framing *per image*, not per element.

## 3. The fix, in principle

Give every photo its own focal point, and swap the focal at the same moment you
swap the image.

Two constraints make this less trivial than it sounds. **Both are load-bearing —
please don't optimise either away.**

### 3a. During a cross-fade, two photos are on screen at different crops

The incoming photo fades in over the outgoing one. For that 1.3s both are visible
and **each needs its own focal point simultaneously**. If the fade layer simply
inherits its framing from the host element, it cannot — there is only one value to
inherit.

So the fade layer must resolve its **own** `background-position` while a focal list
is in play.

### 3b. …but the layer must still track the host's parallax

The hero also has a scroll parallax that nudges `background-position` vertically.
An earlier version of this code copied framing from host to layer in JavaScript;
the two fell out of sync while scrolling and the same photo showed at two different
crops — a visible double-exposure.

The fix for that was to have the layer inherit. So the per-image focal work must
**not** simply un-inherit everything. The solution is to inherit the *parallax
offset* (a CSS custom property, which still cascades) while resolving the *focal*
locally:

```css
.motion-rotate-layer.has-focal {
  background-position: center calc(var(--layer-focal, 50%) + var(--py, 0px));
}
```

`--layer-focal` is set on the layer (incoming photo). `--py` is set on the host by
the parallax code and inherits down. Result: two different crops, one shared
parallax offset, no drift.

### 3c. Hand image and focal over in the same tick

When the fade completes and the host takes ownership of the new photo, set the
image and the focal **together**, while the layer is still fully opaque on top.
Setting them in separate frames flashes the new photo at the old crop.

## 4. Reference implementation

### Markup

```html
<header class="d-hero"
        data-rotate="…/convocation.jpg | …/BicycleQuad.jpg | …/ArchdaleFall.jpg | …/AprilFest.jpg"
        data-rotate-focal="42% | 28% | 42% | 42%">
```

One focal per image, same order as `data-rotate`. Omit the attribute entirely and
behaviour is unchanged (the layer goes back to inheriting).

### CSS (`motion.css`)

```css
/* default — unchanged */
.motion-rotate-layer {
  background-size: inherit;
  background-position: inherit;
}

/* only when a focal list is present */
.motion-rotate-layer.has-focal {
  background-position: center calc(var(--layer-focal, 50%) + var(--py, 0px));
}
```

### JS (`motion.js`, inside the rotator)

```js
var focals = (el.getAttribute('data-rotate-focal') || '').split('|')
               .map(function (s) { return s.trim(); })
               .filter(function (s) { return s; });

// on init
if (focals.length) {
  layer.classList.add('has-focal');
  if (focals[0]) el.style.setProperty('--focal', focals[0]);   // frame one
}

// starting the fade — layer carries the INCOMING photo's focal
if (focals.length) layer.style.setProperty('--layer-focal', focals[i] || '50%');
layer.style.backgroundImage = "url('" + srcs[i] + "')";

// completing the fade — host takes image AND focal together
el.style.backgroundImage = "url('" + srcs[i] + "')";
if (focals.length) el.style.setProperty('--focal', focals[i] || '50%');
```

## 5. Choosing a focal value

Don't guess, and don't eyeball it only at your own screen size. Method:

1. Find the subject's vertical position in the **original** image, as a fraction of
   its height (the cyclist's head: ~100–190px of 683 → 15–28%).
2. Render the hero at the **shortest** it ever gets — that's the tightest crop and
   the worst case. On this site the hero is 590px at 1440×900 and 450px at
   1440×760, because `min-height` steps down at several breakpoints.
3. Pick a focal that leaves headroom above the subject at that worst case, then
   confirm it still looks right on tall viewports (where it will sit higher).

For the cyclist, **28%** works from 390px wide through 1920×1080.

## 6. Porting to Drupal

The comp hard-codes the focal list because it is a static prototype. In Drupal the
focal point should be **stored with the image**, not with the template.

**Recommended:** the [Focal Point](https://www.drupal.org/project/focal_point)
module. It adds a click-to-set focal point per image (stored via the Crop API), so
whoever uploads the photo sets the crop — no developer round-trip when the hero
rotation changes. Then either:

- **(a) Emit it as CSS.** Print the stored focal into the markup as the
  `data-rotate-focal` list (or as `--focal` on a single-image hero) and keep the
  CSS/JS above unchanged. Most faithful to the comp, and the crop stays responsive
  because the browser recomputes it at every viewport.
- **(b) Crop server-side.** Use focal-point-aware image styles with
  `<picture>`/responsive images. Ships fewer bytes and no layout maths, but the
  crop is fixed per breakpoint at render time and needs `object-fit` rather than a
  CSS background.

**(a) is the closer port** for a full-bleed hero that reflows continuously. (b) is
better if the hero becomes a real `<img>` — worth considering anyway, since a
background image is invisible to assistive tech and carries no `alt`.

**Whichever route:** keep the two constraints in §3a/§3b. Any implementation where
the cross-fade layer and the host share a single framing value will reproduce
either the beheading or the double-exposure.

**Also worth fixing on the way past:** the hero photos in this library are ~1024px
on the long edge and are being upscaled to 1440px+. Higher-resolution originals
would help more than any amount of crop tuning.

## 7. Verifying the port

The checks that caught both the original bug and the drift regression:

1. **Worst-case crop.** Render each hero photo at the shortest hero height and
   confirm no face or head is clipped. Do this per photo — that is the whole point.
2. **Mid-fade state.** Sample `getComputedStyle` on host and layer *during* a
   cross-fade. Expect two different focals and the **same** parallax term:
   ```
   host  : 50% calc(42% - 14px)   convocation
   layer : 50% calc(28% - 14px)   cyclist        ← different focal, same -14px
   ```
3. **After handoff.** Host shows the new photo at the new focal; layer at opacity 0.
4. **No drift while scrolling.** Scroll during a fade; both positions must move
   together.
5. **Rotators without a focal list** must be untouched — layer inherits, no
   `has-focal` class.
6. Standard sweep: no horizontal overflow, no console errors, all images 200.

---

*Ellis Agency · Guilford College website refresh · Jul 2026*
