perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -18,7 +18,13 @@ import {
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
import { useShowcaseItems, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
import { ProjectActions } from "./ProjectActions";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import {
|
||||
type ShowcaseItem,
|
||||
useShowcaseItems,
|
||||
useShowcaseOpen,
|
||||
} from "./ShowcaseProvider";
|
||||
import {
|
||||
easeToward,
|
||||
locateInReel,
|
||||
@@ -26,6 +32,11 @@ import {
|
||||
reelOffset,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
import {
|
||||
usePinnedViewport,
|
||||
useSaveData,
|
||||
useViewportDevice,
|
||||
} from "./useShowcaseMedia";
|
||||
|
||||
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
||||
let headerCache = { width: -1, height: 60 };
|
||||
@@ -43,25 +54,6 @@ const headerHeight = () => {
|
||||
return headerCache.height;
|
||||
};
|
||||
|
||||
/** Verdadeiro enquanto a tela é estreita a ponto de a vitrine mostrar só o celular (o notebook fica escondido). */
|
||||
function usePhoneOnly() {
|
||||
const [phoneOnly, setPhoneOnly] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia("(max-width: 639px)");
|
||||
const update = () => setPhoneOnly(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
return phoneOnly;
|
||||
}
|
||||
|
||||
/** "Economizar dados" ligado: a vitrine fica nos posters, sem baixar os vídeos de rolagem. */
|
||||
const saveDataOn = () =>
|
||||
(navigator as Navigator & { connection?: { saveData?: boolean } }).connection
|
||||
?.saveData === true;
|
||||
|
||||
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
|
||||
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
||||
if (!video || video.readyState < 1 || !video.duration) return;
|
||||
@@ -111,25 +103,68 @@ function scrollToSmoothly(top: number) {
|
||||
id = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projetos da home. Em tablet e desktop (768 px de largura e 561 px de altura, no mínimo), a vitrine animada:
|
||||
* o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a página rola, com a
|
||||
* cena presa (position: sticky) durante a rolagem de todos os projetos. No celular, com prefers-reduced-motion
|
||||
* e em janelas baixas, nada fica preso: é a lista de projetos (carrossel por toque no celular), só com pôsteres.
|
||||
* O servidor entrega a lista (texto e links); o navegador troca pela vitrine fixa quando ela cabe, e até lá
|
||||
* um bloco da mesma altura segura o lugar dela. Funciona sozinha dentro de `ShowcaseProvider`: não depende
|
||||
* dos cards da grade.
|
||||
*/
|
||||
export function ProjectReel() {
|
||||
const items = useShowcaseItems();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const wide = usePinnedViewport();
|
||||
const device = useViewportDevice();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const mounted = wide !== null && device !== null;
|
||||
if (mounted && wide && !reducedMotion) return <PinnedReel items={items} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectList items={items} device={device} pending={!mounted} />
|
||||
{!mounted && (
|
||||
// antes de montar, um bloco da mesma altura segura o lugar da vitrine fixa: a página não muda de
|
||||
// tamanho quando ela aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS só o
|
||||
// mostra onde a vitrine fixa vai existir (e a lista, nesses casos, fica escondida)
|
||||
<div
|
||||
className="ds-reel ds-reel-placeholder"
|
||||
aria-hidden="true"
|
||||
style={
|
||||
{
|
||||
"--reel-sum": items.reduce(
|
||||
(sum, item) => sum + item.config.weight,
|
||||
0,
|
||||
),
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
|
||||
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
|
||||
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular
|
||||
* mostram o mesmo trecho do site ao mesmo tempo.
|
||||
*
|
||||
* Só existe com JavaScript e sem prefers-reduced-motion; antes disso, e nesses casos, os cards fazem o papel.
|
||||
* Mídia: só o projeto em cena baixa vídeo (os vizinhos ficam no pôster), e só quando a vitrine está a ~1 tela
|
||||
* da janela; com "economizar dados" ninguém baixa vídeo. O vídeo de celular só é pedido se a moldura do
|
||||
* celular está à vista.
|
||||
*/
|
||||
export function ProjectReel() {
|
||||
const items = useShowcaseItems();
|
||||
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const openProject = useShowcaseOpen();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [chapter, setChapter] = useState(0);
|
||||
const [near, setNear] = useState(false);
|
||||
const [settled, setSettled] = useState(0);
|
||||
const [saveData, setSaveData] = useState(false);
|
||||
const phoneOnly = usePhoneOnly();
|
||||
const [phoneVisible, setPhoneVisible] = useState(true);
|
||||
const saveData = useSaveData();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pinRef = useRef<HTMLDivElement>(null);
|
||||
@@ -145,6 +180,7 @@ export function ProjectReel() {
|
||||
raf: 0,
|
||||
last: 0,
|
||||
near: false,
|
||||
phone: true,
|
||||
});
|
||||
|
||||
const weights = useMemo(
|
||||
@@ -153,13 +189,8 @@ export function ProjectReel() {
|
||||
);
|
||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
|
||||
useEffect(() => {
|
||||
setSaveData(saveDataOn());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// só o projeto em cena e os vizinhos baixam vídeo; e só depois que a rolagem parou ali (passar voando por
|
||||
// um projeto no clique do índice não pode baixar todos os do caminho)
|
||||
// só o projeto em cena baixa vídeo (os vizinhos ficam no pôster); e só depois que a rolagem parou ali (passar
|
||||
// voando por um projeto no clique do índice não pode baixar todos os do caminho)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettled(active), 300);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -183,9 +214,11 @@ export function ProjectReel() {
|
||||
}, [active]);
|
||||
|
||||
const isAttached = useCallback(
|
||||
(index: number) => near && !saveData && Math.abs(index - settled) <= 1,
|
||||
(index: number) => near && !saveData && index === settled,
|
||||
[near, saveData, settled],
|
||||
);
|
||||
// o pôster de quem está em cena e dos vizinhos já fica pronto para a troca; os outros esperam chegar perto
|
||||
const showsPoster = (index: number) => near && Math.abs(index - active) <= 1;
|
||||
|
||||
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
||||
useEffect(() => {
|
||||
@@ -266,12 +299,20 @@ export function ProjectReel() {
|
||||
state.target = items.map((_, i) =>
|
||||
i < index ? 1 : i === index ? videoProgress(local) : 0,
|
||||
);
|
||||
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
|
||||
const near =
|
||||
rect.top < window.innerHeight * 2 && rect.bottom > -window.innerHeight;
|
||||
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
|
||||
if (near !== state.near) {
|
||||
state.near = near;
|
||||
setNear(near);
|
||||
}
|
||||
// o celular da moldura só baixa vídeo se está à vista (tem tamanho na tela)
|
||||
const phone = container.querySelector<HTMLElement>(".ds-reel-phone");
|
||||
const phoneShown = Boolean(phone && phone.offsetWidth > 0);
|
||||
if (phoneShown !== state.phone) {
|
||||
state.phone = phoneShown;
|
||||
setPhoneVisible(phoneShown);
|
||||
}
|
||||
|
||||
if (index !== state.active) {
|
||||
state.active = index;
|
||||
@@ -289,7 +330,6 @@ export function ProjectReel() {
|
||||
}, [frame, items, weights]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || reducedMotion || items.length === 0) return;
|
||||
let pending = 0;
|
||||
const schedule = () => {
|
||||
if (!pending)
|
||||
@@ -309,7 +349,7 @@ export function ProjectReel() {
|
||||
cancelAnimationFrame(state.raf);
|
||||
state.raf = 0;
|
||||
};
|
||||
}, [mounted, reducedMotion, items.length, measure]);
|
||||
}, [measure]);
|
||||
|
||||
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||
const scrollToReel = (index: number, local: number) => {
|
||||
@@ -334,20 +374,6 @@ export function ProjectReel() {
|
||||
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
||||
};
|
||||
|
||||
if (items.length === 0 || (mounted && reducedMotion)) return null;
|
||||
|
||||
// antes de montar, um bloco da mesma altura segura o lugar: a página não muda de tamanho quando a vitrine
|
||||
// aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS o esconde sem JavaScript, com
|
||||
// prefers-reduced-motion e em telas baixas
|
||||
if (!mounted)
|
||||
return (
|
||||
<div
|
||||
className="ds-reel ds-reel-placeholder"
|
||||
aria-hidden="true"
|
||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
|
||||
const current = items[active];
|
||||
|
||||
return (
|
||||
@@ -357,6 +383,7 @@ export function ProjectReel() {
|
||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||
ref={containerRef}
|
||||
>
|
||||
<h2 className="sr-only">Projetos de clientes</h2>
|
||||
<div className="ds-reel-pin" ref={pinRef}>
|
||||
<div className="ds-reel-grid">
|
||||
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
||||
@@ -374,9 +401,12 @@ export function ProjectReel() {
|
||||
aria-label={item.project.title}
|
||||
onClick={() => goTo(index)}
|
||||
>
|
||||
<span className="ds-reel-num">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
|
||||
<span
|
||||
className="ds-reel-num"
|
||||
aria-hidden="true"
|
||||
data-n={String(index + 1).padStart(2, "0")}
|
||||
/>
|
||||
<span className="ds-reel-name">{item.project.title}</span>
|
||||
<i
|
||||
className="ds-reel-bar"
|
||||
@@ -417,6 +447,7 @@ export function ProjectReel() {
|
||||
const { config, project } = item;
|
||||
const isActive = index === active;
|
||||
const attach = isAttached(index);
|
||||
const posters = showsPoster(index);
|
||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||
const phone = getShowcaseAssets(config.id, "mobile");
|
||||
return (
|
||||
@@ -438,22 +469,25 @@ export function ProjectReel() {
|
||||
>
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={{ backgroundImage: `url(${desktop.poster})` }}
|
||||
style={
|
||||
posters
|
||||
? { backgroundImage: `url(${desktop.poster})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
||||
<video
|
||||
ref={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach && !phoneOnly
|
||||
attach
|
||||
? getScrubSource(config.id, "desktop")
|
||||
: undefined
|
||||
}
|
||||
poster={desktop.poster}
|
||||
poster={posters ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload={attach && !phoneOnly ? "auto" : "none"}
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
@@ -465,22 +499,21 @@ export function ProjectReel() {
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={phone.poster}
|
||||
poster={posters ? phone.poster : undefined}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
||||
<video
|
||||
ref={(element) => {
|
||||
phoneVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach
|
||||
attach && phoneVisible
|
||||
? getScrubSource(config.id, "mobile")
|
||||
: undefined
|
||||
}
|
||||
poster={phone.poster}
|
||||
poster={posters ? phone.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload={attach ? "auto" : "none"}
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
@@ -525,31 +558,10 @@ export function ProjectReel() {
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="ds-reel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ds-link ds-link-accent"
|
||||
onClick={() => openProject(config.id)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
Ampliar vídeo
|
||||
</button>
|
||||
{project.demoUrl &&
|
||||
project.demoAvailability !== "unavailable" && (
|
||||
<a
|
||||
className="ds-link ds-link-muted"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir projeto
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
(abre em nova aba)
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<ProjectActions
|
||||
item={item}
|
||||
onExpand={() => openProject(config.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user