跳到正文
DEV Community· Vicente G. Reyes·· 4 小時前AI 評分27

案例研究:如何讓我的作品集更鮮明而不拖慢速度

Case Study: Making My Portfolio Loud (Without Making It Slow)

AI 導讀

作者把個人作品集改為更大膽的極繁主義設計,同時以效能和可用性為設計原則。改版只涉及前端,沿用現有 Django REST API,並以 Motion 建立動畫元件;動畫程式碼獨立打包,亦照顧偏好減少動態效果的用戶和觸控裝置。

正文

My portfolio worked fine. That was the problem.

It was a tidy neubrutalist site: yellow, cyan and green cards, thick black borders, hard drop shadows, a dark-mode toggle. And it did its job: it helped me land Oatopia, Rev6 and Digital Trek (Rodentia Pest Products) as clients.

But it looked like a lot of other neubrutalist portfolios, and a UX audit I'd been working through kept pointing out the same thing. It was clean and it was forgettable.

So I tried something I'd normally talk myself out of. I went maximalist. Kinetic type, colours that clash, stickers you can throw around, marquees that react to your scroll. Internally I called it the loud redesign.

This post covers what I built, the rules that kept it usable, and the bugs that caught me out.

Live site: vicentereyes.org


The brief I gave myself

  1. Loud, but still a portfolio. Every animation had to sit on top of real content: projects, services, testimonials, blog posts. Nothing decorative could block reading or contacting me.
  2. No new backend. Everything still comes from my existing Django REST API. The redesign was frontend only.
  3. Respect the user. prefers-reduced-motion and touch devices get a proper experience, not a broken one.
  4. Don't tax every page for the homepage's party tricks. Animation code ships in its own chunk.

Step 1: Prototype on a hidden route

I didn't touch the live homepage at first. I built the whole thing on a hidden /loud route so I could compare it with the real site side by side and throw it away if it didn't work.

That first commit added a set of reusable motion primitives under components/motion/:

Primitive What it does
KineticText Splits a headline into letters with a staggered spring entrance; letters get pushed away from the cursor
Marquee Infinite ticker whose speed and skew follow scroll velocity
StickerWall Draggable, throwable skill stickers kept inside their section
HorizontalScroll A pinned section where vertical scrolling moves you sideways
MagneticButton, LiftCard, Parallax, Counter, CustomCursor, ScrollProgress, IntroLoader The supporting cast

All of them are built on Motion (the library formerly known as Framer Motion).

Once /loud felt right, I moved it onto the real homepage, wired it to the live API hooks I already had, and redirected /loud to /.


Step 2: A design system made of clashes

The new palette is deliberately louder than the old one:

@theme {
  --color-loud-lime: #a3e636;
  --color-loud-ink: #111903;
  --color-loud-cool: #e0e7f1;
  --color-loud-paper: #eeefe9;
  --color-loud-blue: #2d5bff;
  --color-loud-orange: #ff7a00;
  --color-loud-purple: #8b5cf6;

  --shadow-loud: 8px 8px 0 #111903;
  --shadow-loud-sm: 4px 4px 0 #111903;

  --font-anton: Anton, Impact, sans-serif;
  --font-jet: 'JetBrains Mono', ui-monospace, monospace;
}

Anton for the giant headlines, JetBrains Mono for the sticker labels, Space Grotesk for body copy, all on a grid-paper background.

The hard part was making API data look hand-placed. Projects, services and FAQs come from the backend in whatever order and quantity they come in, so I couldn't art-direct each card. Instead, each card gets a colour and a tilt based on its position in the list:

export const FACES = [
  'bg-loud-lime',
  'bg-loud-blue text-loud-paper',
  'bg-loud-orange',
  'bg-loud-purple text-loud-paper',
  'bg-loud-cool',
  'bg-loud-paper',
];

// Small fixed tilts so lists look hand-placed but stay stable between renders.
const TILTS = [-2, 2.5, -3, 3, -1.5, 2, -2.5, 1.5];
export const tilt = (i: number) => TILTS[i % TILTS.length];
export const face = (i: number) => FACES[i % FACES.length];

It looks random, but it's deterministic. Nothing reshuffles when the page re-renders, so you don't get layout jank or hydration surprises. The featured-work grid works the same way, with a repeating pattern of bento column and row spans.

Huge headlines that fill the width need some maths. Anton capitals are about 0.47em wide, so each line gets a font size that keeps it under roughly 92vw:

export const fitLine = (text: string, max = 17) => ({
  fontSize: `min(${max}vw, ${(92 / (Math.max(text.length, 1) * 0.47)).toFixed(2)}vw)`,
});

Step 3: Motion that doesn't fight the browser

Kinetic text without layout thrashing

The hero headline reacts to your cursor letter by letter. The obvious way to build that is to call getBoundingClientRect() on every letter on every pointermove, which is a great way to drop frames.

What I did instead:

  • One shared pointer listener feeds every component, through Motion values:
  let pointer: { x: MotionValue<number>; y: MotionValue<number> } | null = null;

  export function usePointer() {
    if (!pointer) {
      pointer = { x: motionValue(-9999), y: motionValue(-9999) };
      window.addEventListener('pointermove', (e) => {
        pointer!.x.set(e.clientX);
        pointer!.y.set(e.clientY);
      }, { passive: true });
    }
    return pointer;
  }
  • Each letter measures its centre once (and again on resize and when fonts finish loading) by walking the offsetParent chain. That skips transforms, so the letter's own animation doesn't change the measurement.
  • The per-frame work is pure maths on cached numbers: the distance to the pointer becomes a force, and the force drives springs for x, y, rotation and scale. No DOM reads happen while the pointer moves.

On touch devices there's no hover, so tapping a letter makes it jump instead.

Marquees that speed up with your scroll

My first marquee set its speed as a percentage of the track. That meant a marquee with more items scrolled faster in pixels, and every band on the page moved at a different speed. The fix was to define speed in pixels per second and convert it to a percentage using a track width that only gets read on resize:

useAnimationFrame((_, delta) => {
  if (reduced || !inView || !trackWidth.current) return;
  // px/s → percent of the track, so speed doesn't depend on content length.
  let moveBy = direction.current * ((baseVelocity * (delta / 1000)) / trackWidth.current) * 100;
  const factor = velocityFactor.get();
  if (factor < 0) direction.current = -1;
  else if (factor > 0) direction.current = 1;
  moveBy += direction.current * moveBy * factor;
  baseX.set(baseX.get() + moveBy);
});

Scroll velocity also pushes a small skewX, so the bands lean as you scroll fast. Marquees that are off screen (!inView) skip their frame updates entirely.

Case studies that grow out of their card

Clicking a featured project opens a full-screen case study that expands from the card itself, using Motion's shared layoutId. The overlay is lazy-loaded, so react-markdown stays out of the homepage's first load. It handles focus, closes on Escape and locks page scrolling while it's open.

The intro plays once

The homepage has an intro loader. Seeing it every time you come back to the homepage would get old quickly, so it runs once per session behind a sessionStorage flag wrapped in try/catch (private windows can throw).


Step 4: Accessibility rules I didn't break

A maximalist site is where accessibility tends to get dropped. These were non-negotiable:

  • Reduced motion is a real mode. Marquee renders as static text with no skew. HorizontalScroll becomes a normal vertical stack. Letter entrances become a simple fade. Parallax layers stay still.
  • Cursor effects need a fine pointer and motion allowed:
  export function usePointerEffects(): boolean {
    const isTouch = useIsTouch(); // (hover: none), (pointer: coarse)
    const reduced = useReducedMotion();
    return !isTouch && !reduced;
  }
  • Split text is still readable. KineticText puts aria-label with the full headline on the wrapper and hides the individual letter spans from screen readers.
  • Duplicated marquee copies (it renders four for a seamless loop) are aria-hidden after the first.
  • The case study overlay is a proper role="dialog" with aria-modal and aria-labelledby, and focus moves to the close button.

Step 5: Rebuild every page

A loud homepage next to a quiet inner site would have felt like two different websites, so I rebuilt every page in the new style:

  • About and its sub-pages (Now, Uses, Music, Certifications)
  • Blog list, posts, series and tag pages: grid-paper backgrounds, sticker metadata, article prose restyled to the new palette
  • Services list and detail, plus the Works hub, Professional, Personal, Audio, Tech and project pages
  • Contact, Guestbook (entries are now sticky notes), Privacy, Terms, 404 and Unsubscribe
  • The cookie banner, resume modal and loading/error fallbacks

Doing this many pages forced me to extract shared pieces: LoudLayout (the page shell), LoudPageHeader (kinetic title plus sub-page tabs), LoudPostGrid, LoudProjectBrowser (search, category filters, animated grid), LoudFaqList, and LoudBriefSection. That last one replaced three separate copies of the inline contact form.

The form logic moved into useContactForm and useNewsletterForm hooks, so the old and new UIs submitted the same way during the transition. That made the switch-over low-risk.


Step 6: Delete everything

This is the part I'm proudest of. After the rebuild, I removed:

  • Components: the old Navbar, Footer, Hero, Projects, Services, Stats, Testimonials, FAQs, Button, Card, DevIcon, the old project and service modals, and an AI assistant widget that wasn't earning its place
  • Packages: framer-motion (as a direct dependency), lucide-react, react-countup, react-intersection-observer and @google/genai
  • Dark mode. The loud design is light-only on purpose: the whole look depends on ink on paper. LoudLayout removes the dark class while it's mounted.

Across the redesign: 86 files changed, 5,423 lines added, 8,454 lines removed. The site does more than it did before, with about 3,000 fewer lines of code.

Bundle split

Motion gets its own chunk, so pages that don't animate much don't pay for it up front. Because motion depends on framer-motion internally, they have to go in the same chunk, or you end up with duplicated runtime code:

manualChunks(id) {
  if (['react', 'react-dom', 'react-router-dom', 'react-helmet-async'].some(pkg => id.includes(`/node_modules/${pkg}/`))) {
    return 'chunk-react';
  }
  // motion depends on framer-motion internally; keep the whole animation stack in one chunk.
  if (['motion', 'motion-dom', 'motion-utils', 'framer-motion'].some(pkg => id.includes(`/node_modules/${pkg}/`))) return 'chunk-motion';
  if (id.includes('/node_modules/@sentry/')) return 'chunk-sentry';
},

Current production build, gzipped:

Chunk Size (gzip)
App entry ~26 KB
React + router ~71 KB
Motion ~56 KB
Sentry ~54 KB
Each page 0.5–7.5 KB raw

Every page is lazy-loaded, so a visitor only downloads the page they're looking at.


Gotchas I hit

1. overflow-x: hidden broke position: sticky.
The pinned horizontal-scroll section wouldn't stick. The cause was a global overflow-x: hidden on html and body, which turns body into a scroll container and breaks sticky positioning for everything inside it. The fix is overflow-x: clip, which hides overflow without creating a scroll container:

// The global overflow-x: hidden on html/body makes body a scroll container,
// which breaks position: sticky (HorizontalScroll). clip hides overflow
// without that side effect.
els.forEach((el) => (el.style.overflowX = 'clip'));

2. A CommonJS-only package crashed the homepage under Vite 8.
During a dependency upgrade, react-countup (which only ships CommonJS) had its default import resolved to the entire exports object. React tried to render that object, threw error #130, and the homepage crashed as soon as the stats section scrolled into view. I unwrapped .default as a quick fix, then removed the package in the cleanup.

3. @vercel/og 0.11 and later wouldn't build on Edge.
The newer edge build imports Node's module (createRequire), which Vercel Edge Functions reject at build time. I pinned it to 0.11.1.

4. Social previews were quietly broken.
The fallback og:image pointed at a Twitter profile-photo URL that had started returning 404, so links to the homepage were shared with no image. It's now a self-hosted 1200×630 card with og:image:width and og:image:height declared, so Facebook renders it on the first share instead of processing it in the background. Detail pages (case studies, blog tags, series, tech) also get their own generated OG images from an edge function.

5. Phones needed their own layout.
The first version of the hero looked great on desktop and fell apart on phones. Phones now get one word per headline line, the stickers sit above the headline instead of scattered around it, the hero height is capped on tall tablets, and the bento grid only uses four columns at lg and up.


What I'd do differently

  • Plan for reduced motion from day one. I did this, and it's the only reason the reduced-motion version doesn't feel like an afterthought. Every primitive decides its fallback up front.
  • Prototype on a hidden route again. Being able to compare /loud with the live site was the most useful decision in the whole project.
  • Extract shared components sooner. I wrote the contact form three times before turning it into LoudBriefSection. I should have stopped at two.

Wrapping up

The redesign took four days, from the first /loud prototype to the final cleanup. The site is louder and lighter, and every page shares one set of components.

Go throw some stickers around at vicentereyes.org, and if you're planning a site that needs to stand out, let's talk.

Built with React 19, Vite 8, Tailwind CSS 4 and Motion, on a Django REST backend.

來源:DEV Community · dev.to