Testimonials: center, scale by length, smoother fade

Center-align the testimonial card (text + attribution row), then
size the quote based on character count so short, punchy quotes get
the spotlight and long ones tighten down:

  is-xshort (<=90  chars)  3rem cap
  is-short  (<=180 chars)  2.25rem cap
  is-medium (<=320 chars)  1.55rem cap (the previous baseline)
  is-long   (<=520 chars)  1.20rem cap
  is-xlong  (>520  chars)  1.05rem cap

Counts use [...str] so grapheme-cluster emoji (skin-tone, ZWJ) count
as a single visual character instead of inflating the length.

Crossfade: drop the secondary translateY (it stuttered against
centered content), bump duration 600ms -> 1100ms, swap easing to
cubic-bezier(0.65, 0, 0.35, 1) for a softer in/out, and add
will-change:opacity so the browser keeps the layer on the GPU.
This commit is contained in:
Joel Brock
2026-05-15 17:33:29 -07:00
parent e1b3bc7d43
commit b257d92636
2 changed files with 47 additions and 14 deletions

View File

@@ -493,16 +493,15 @@ body::before {
grid-area: stack; grid-area: stack;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(8px); transition: opacity 1100ms cubic-bezier(0.65, 0, 0.35, 1), visibility 0s linear 1100ms;
transition: opacity 600ms var(--ease-strong), transform 600ms var(--ease-strong), visibility 0s linear 600ms;
pointer-events: none; pointer-events: none;
will-change: opacity;
} }
.carousel__track > .is-active { .carousel__track > .is-active {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transform: none;
pointer-events: auto; pointer-events: auto;
transition: opacity 600ms var(--ease-strong), transform 600ms var(--ease-strong), visibility 0s; transition: opacity 1100ms cubic-bezier(0.65, 0, 0.35, 1), visibility 0s;
} }
.carousel--stacked .carousel__track { .carousel--stacked .carousel__track {
display: flex; display: flex;
@@ -545,9 +544,11 @@ body::before {
/* ───── testimonial card ───── */ /* ───── testimonial card ───── */
.card--testimonial { .card--testimonial {
padding: clamp(1.75rem, 3vw, 2.75rem); padding: clamp(1.75rem, 3vw, 3rem) clamp(1.5rem, 4vw, 3.5rem);
display: flex; flex-direction: column; display: flex; flex-direction: column;
gap: 1.5rem; align-items: center;
text-align: center;
gap: 1.25rem;
min-height: clamp(220px, 24vw, 300px); min-height: clamp(220px, 24vw, 300px);
} }
.card--testimonial::after { display: none; } .card--testimonial::after { display: none; }
@@ -559,25 +560,50 @@ body::before {
font-style: italic; font-style: italic;
font-variation-settings: "opsz" 144, "SOFT" 100; font-variation-settings: "opsz" 144, "SOFT" 100;
font-weight: 400; font-weight: 400;
align-self: start;
user-select: none; user-select: none;
} }
.testimonial__quote { .testimonial__quote {
font-family: var(--display); font-family: var(--display);
font-size: clamp(1.15rem, 0.95rem + 0.7vw, 1.45rem); font-size: var(--quote-fs, clamp(1.2rem, 0.95rem + 0.85vw, 1.55rem));
line-height: 1.45; line-height: var(--quote-lh, 1.45);
font-weight: 400; font-weight: 400;
font-variation-settings: "opsz" 60, "SOFT" 100; font-variation-settings: "opsz" 60, "SOFT" 100;
color: var(--ink); color: var(--ink);
margin: 0; margin: 0;
letter-spacing: -0.005em; letter-spacing: -0.005em;
max-width: 60ch; max-width: 64ch;
text-wrap: balance;
} }
/* length-driven scale: shorter quotes get the spotlight, longer ones tighten */
.card--testimonial.is-xshort {
--quote-fs: clamp(1.85rem, 1.2rem + 2.4vw, 3rem);
--quote-lh: 1.20;
}
.card--testimonial.is-short {
--quote-fs: clamp(1.5rem, 1.05rem + 1.7vw, 2.25rem);
--quote-lh: 1.30;
}
.card--testimonial.is-medium {
--quote-fs: clamp(1.2rem, 0.95rem + 0.85vw, 1.55rem);
--quote-lh: 1.45;
}
.card--testimonial.is-long {
--quote-fs: clamp(1.05rem, 0.95rem + 0.35vw, 1.20rem);
--quote-lh: 1.55;
}
.card--testimonial.is-xlong {
--quote-fs: clamp(0.95rem, 0.92rem + 0.18vw, 1.05rem);
--quote-lh: 1.6;
}
.testimonial__by { .testimonial__by {
display: flex; align-items: center; gap: 0.85rem; display: flex; align-items: center; justify-content: center; gap: 0.85rem;
margin-top: auto; margin-top: auto;
padding-top: 0.5rem; padding-top: 0.85rem;
border-top: 1px solid var(--rule); border-top: 1px solid var(--rule);
width: 100%;
max-width: 32rem;
} }
.testimonial__avatar { .testimonial__avatar {
flex: 0 0 auto; flex: 0 0 auto;
@@ -590,7 +616,7 @@ body::before {
} }
.testimonial__avatar img { width: 100%; height: 100%; object-fit: cover; } .testimonial__avatar img { width: 100%; height: 100%; object-fit: cover; }
.testimonial__avatar img.is-favicon { width: 60%; height: 60%; object-fit: contain; } .testimonial__avatar img.is-favicon { width: 60%; height: 60%; object-fit: contain; }
.testimonial__attr { display: flex; flex-direction: column; gap: 0.1rem; line-height: 1.3; min-width: 0; } .testimonial__attr { display: flex; flex-direction: column; gap: 0.1rem; line-height: 1.3; min-width: 0; text-align: left; }
.testimonial__name { .testimonial__name {
font-family: var(--body); font-family: var(--body);
font-weight: 600; font-weight: 600;

View File

@@ -117,8 +117,15 @@
const isFavicon = !(it.image || it.icon); const isFavicon = !(it.image || it.icon);
const initial = (it.name || it.org || "·").trim().charAt(0).toUpperCase(); const initial = (it.name || it.org || "·").trim().charAt(0).toUpperCase();
const meta = [it.role, it.org].filter(Boolean).join(", "); const meta = [it.role, it.org].filter(Boolean).join(", ");
const len = [...String(it.quote || "")].length;
const lengthClass =
len <= 90 ? " is-xshort" :
len <= 180 ? " is-short" :
len <= 320 ? " is-medium" :
len <= 520 ? " is-long" :
" is-xlong";
return ` return `
<${tag} class="card card--testimonial reveal" ${attrs}> <${tag} class="card card--testimonial reveal${lengthClass}" ${attrs}>
<span class="testimonial__mark" aria-hidden="true">&ldquo;</span> <span class="testimonial__mark" aria-hidden="true">&ldquo;</span>
<blockquote class="testimonial__quote">${esc(it.quote)}</blockquote> <blockquote class="testimonial__quote">${esc(it.quote)}</blockquote>
<figcaption class="testimonial__by"> <figcaption class="testimonial__by">