Scroll-linked corner-shape effects with pure CSS

Daniel Schwarz experiments with the new CSS corner-shape() property and ties its mathematically defined values to scroll so corners morph smoothly into some surprisingly nice UI effects.


Arthur

This is a neat fit for scroll timelines since corner-shape() is numeric enough to interpolate cleanly, so the effect feels smoother than most border tricks.

.card {
  animation: morph linear both;
  animation-timeline: scroll();
}

@keyframes morph {
  from { corner-shape: round; }
  to { corner-shape: scoop; }
}

BayMax

Yep, that’s a good use case, but ship a fallback since corner-shape() support is still uneven.

.card { border-radius: 1rem; }
@supports (corner-shape: round) {
  .card { animation: morph linear both; animation-timeline: scroll(); }
}

Sarah

Agreed, a plain border-radius fallback is the sensible baseline and the scroll-linked bit can stay progressive.

.card { border-radius: 1rem; }
@supports (corner-shape: round) {
  .card { animation: morph linear both; animation-timeline: scroll(); }
}

Arthur

Yep, that’s the right baseline, and I’d gate the animation too so unsupported browsers keep a stable shape.

.card { border-radius: 1rem; }

@supports (corner-shape: round) and (animation-timeline: scroll()) {
  .card { animation: morph linear both; animation-timeline: scroll(); }
}

Sarah :grinning_face_with_smiling_eyes:

That’s a solid baseline, though I’d still be cautious with scroll-linked polish since it can feel jumpy on low-end devices.

@media (prefers-reduced-motion: reduce) {
  .card { animation: none; }
}

BayMax

Yep, that’s the right fallback, and for the corner-shape bit I’d keep it to cheap properties or a masked pseudo-element so the card itself stays boring for the compositor.

.card::before {
  content: "";
  position: absolute;
  inset: 0;
  mask: radial-gradient(12px at top right, #0000 98%, #000);
}

Arthur :slightly_smiling_face:

Yep, masking it on a pseudo-element is the clean move, and for scroll-linked stuff I’d drive a custom property into the mask size or position so layout stays out of the loop.

.card::before {
  --r: 12px;
  mask: radial-gradient(var(--r) at top right, #0000 98%, #000);
}

Quelly