To do2
Done1
We shipped the redesign two weeks early, and the motion finally feels like one product instead of five.import { useEffect, useRef, useState } from 'react';
const css = `
.card-highlight { box-sizing: border-box; margin: 0; width: 240px; padding: 16px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-highlight__mark-q { display: block; height: 22px; font: 700 40px/1 Georgia, serif; color: light-dark(#6d7179, #7d8087); }
.card-highlight__quote { margin: 4px 0 14px; font-size: 13px; line-height: 1.55; color: light-dark(#373a40, #f1f2f3); }
/* The marker is a background that grows from 0 to full width; clone keeps it going across line breaks. */
.card-highlight__hl {
padding: 0 1px;
background: linear-gradient(light-dark(#cbced3, #393c41), light-dark(#cbced3, #393c41)) no-repeat 0 60% / 0% 80%;
color: inherit;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
transition: background-size 0.6s cubic-bezier(0.5, 0, 0.2, 1);
}
.card-highlight--on .card-highlight__hl { background-size: 100% 80%; }
.card-highlight--on .card-highlight__hl--2 { transition-delay: 0.6s; }
.card-highlight:not(.card-highlight--on) .card-highlight__hl { transition: none; }
.card-highlight__who { display: flex; align-items: center; gap: 10px; }
.card-highlight__avatar { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); font-size: 11px; font-weight: 700; }
.card-highlight__name { display: block; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-highlight__role { display: block; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.card-highlight__hl { transition: none; }
}
`;
export function HighlightCard() {
const [on, setOn] = useState(false);
const box = useRef<HTMLElement>(null);
// Sweep once the card is mostly in view; hovering sweeps again.
useEffect(() => {
const io = new IntersectionObserver(([e]) => e.isIntersecting && setOn(true), { threshold: 0.6 });
io.observe(box.current!);
return () => io.disconnect();
}, []);
const replay = () => {
setOn(false);
requestAnimationFrame(() => requestAnimationFrame(() => setOn(true)));
};
return (
<>
<style>
{css}
</style>
<figure ref={box} className={`card-highlight${on ? ' card-highlight--on' : ''}`} onPointerEnter={replay}>
<span className="card-highlight__mark-q" aria-hidden="true">
“
</span>
<blockquote className="card-highlight__quote">
We shipped the redesign <mark className="card-highlight__hl">two weeks early</mark>, and the motion finally feels like{' '}
<mark className="card-highlight__hl card-highlight__hl--2">one product instead of five</mark>.
</blockquote>
<figcaption className="card-highlight__who">
<span className="card-highlight__avatar" aria-hidden="true">
DK
</span>
<span>
<span className="card-highlight__name">Dana Kole</span>
<span className="card-highlight__role">Product designer</span>
</span>
</figcaption>
</figure>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardHighlight(el) on the mounted element instead.
<figure class="card-highlight">
<span class="card-highlight__mark-q" aria-hidden="true">“</span>
<blockquote class="card-highlight__quote">We shipped the redesign <mark class="card-highlight__hl">two weeks early</mark>, and the motion finally feels like <mark class="card-highlight__hl card-highlight__hl--2">one product instead of five</mark>.</blockquote>
<figcaption class="card-highlight__who">
<span class="card-highlight__avatar" aria-hidden="true">DK</span>
<span>
<span class="card-highlight__name">Dana Kole</span>
<span class="card-highlight__role">Product designer</span>
</span>
</figcaption>
</figure>.card-highlight { box-sizing: border-box; margin: 0; width: 240px; padding: 16px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-highlight__mark-q { display: block; height: 22px; font: 700 40px/1 Georgia, serif; color: light-dark(#6d7179, #7d8087); }
.card-highlight__quote { margin: 4px 0 14px; font-size: 13px; line-height: 1.55; color: light-dark(#373a40, #f1f2f3); }
/* The marker is a background that grows from 0 to full width; clone keeps it going across line breaks. */
.card-highlight__hl {
padding: 0 1px;
background: linear-gradient(light-dark(#cbced3, #393c41), light-dark(#cbced3, #393c41)) no-repeat 0 60% / 0% 80%;
color: inherit;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
transition: background-size 0.6s cubic-bezier(0.5, 0, 0.2, 1);
}
.card-highlight--on .card-highlight__hl { background-size: 100% 80%; }
.card-highlight--on .card-highlight__hl--2 { transition-delay: 0.6s; }
.card-highlight:not(.card-highlight--on) .card-highlight__hl { transition: none; }
.card-highlight__who { display: flex; align-items: center; gap: 10px; }
.card-highlight__avatar { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); font-size: 11px; font-weight: 700; }
.card-highlight__name { display: block; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-highlight__role { display: block; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.card-highlight__hl { transition: none; }
}function initCardHighlight(root) {
var raf = 0;
// Sweep once the card is mostly in view; hovering sweeps again.
var io = new IntersectionObserver(
function (entries) {
if (entries[0].isIntersecting) root.classList.add('card-highlight--on');
},
{ threshold: 0.6 },
);
io.observe(root);
function replay() {
root.classList.remove('card-highlight--on');
cancelAnimationFrame(raf);
raf = requestAnimationFrame(function () {
raf = requestAnimationFrame(function () {
root.classList.add('card-highlight--on');
});
});
}
root.addEventListener('pointerenter', replay);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
root.removeEventListener('pointerenter', replay);
};
}
document.querySelectorAll('.card-highlight').forEach(initCardHighlight);Goal: Create a "Highlight" card.
Visual behavior: A testimonial whose key phrases get a marker swept behind them, one after the other and across line breaks, when the card comes into view; hover it to sweep again.
Technique:
- Each phrase is a <mark> whose background is a solid linear-gradient sized 0% wide, growing to 100%; box-decoration-break: clone carries the sweep across a line break.
- The second phrase has a transition delay, so it starts as the first one finishes. An IntersectionObserver adds the class when most of the card is in view.
- Hovering removes the class with no transition, then adds it back two frames later, so the sweep replays from the start.
Accessibility: Real <mark> elements in a <blockquote> with a <figcaption>, so the emphasis and the attribution are in the markup. Under prefers-reduced-motion the marks appear without the sweep.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Highlight" card.
Visual behavior: A testimonial whose key phrases get a marker swept behind them, one after the other and across line breaks, when the card comes into view; hover it to sweep again.
Technique:
- Each phrase is a <mark> whose background is a solid linear-gradient sized 0% wide, growing to 100%; box-decoration-break: clone carries the sweep across a line break.
- The second phrase has a transition delay, so it starts as the first one finishes. An IntersectionObserver adds the class when most of the card is in view.
- Hovering removes the class with no transition, then adds it back two frames later, so the sweep replays from the start.
Accessibility: Real <mark> elements in a <blockquote> with a <figcaption>, so the emphasis and the attribution are in the markup. Under prefers-reduced-motion the marks appear without the sweep.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
A testimonial whose key phrases get a marker swept behind them, one after the other and across line breaks, when the card comes into view; hover it to sweep again.
Real <mark> elements in a <blockquote> with a <figcaption>, so the emphasis and the attribution are in the markup. Under prefers-reduced-motion the marks appear without the sweep.
To do2
Done1
Ridge walk, 6am. Double-click the photo.
Standup moved to 10:30