GODRICH

프로젝트 포트폴리오 템플릿 9종 — 쇼케이스 섹션 모음

프로젝트 포트폴리오 템플릿은 그리드·케이스 스터디·경력·연락 폼 같은 개인 포트폴리오 페이지 구성 요소를 통째로 담은 코드 모음입니다. 랜딩 페이지에 바로 붙이는 히어로부터 쇼케이스 푸터까지, 아홉 개 중 하나는 탭을 누를 때마다 실제 탭 폭을 실측해 인디케이터를 옮깁니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 방문자가 포트폴리오를 훑는 순서대로 놓았습니다. 먼저 누구인지 보여주고(히어로), 작업물을 분류해서 보여주고(프로젝트 그리드), 한 작업을 깊게 설명하고(케이스 상세), 다룰 수 있는 기술을 나열하고(기술 스택), 걸어온 경력을 보여주고(경력 타임라인), 남이 한 말로 신뢰를 더하고(추천사), 연락할 방법을 주고(연락 폼), 이력서를 내려받게 하고(이력서 CTA), 마지막으로 더 볼 곳을 안내합니다(쇼케이스 푸터). 얼굴 사진·실제 프로젝트 스크린샷 자리는 이 사이트의 먹·파랑·크림 토큰만으로 그린 도형 플레이스홀더이고, 파일만 갈아 끼우면 레이아웃은 그대로 남습니다.

01소개 히어로

배경에 깔린 원 세 개가 서로 다른 궤적으로 계속 떠다니고, 그 위에 워드마크(도형 플레이스홀더)·헤드라인·부제·CTA 두 개가 겹치지 않게 자리 잡습니다. 오른쪽 "새 프로젝트 제안받기" 버튼은 누를 때마다 상태와 아이콘 채움이 실제로 바뀝니다.

transformopacityaria-pressed
.ih__orb--1 { animation: ih-float-a 2s ease-in-out infinite; }
@keyframes ih-float-a {
  0%, 100% { transform: translate(0, 0) scale(1); }
  50%      { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
.ih__avail-btn[aria-pressed="true"] {
  background: rgba(255, 247, 230, .12);
  border-color: $subject-cream;
}
.ih__avail-btn[aria-pressed="true"] .ih__avail-ic {
  opacity: 1;
  color: $color;
  transform: scale(1.2);
}

02프로젝트 그리드

위쪽 분류 탭(전체·웹·앱·디자인)을 클릭하면 먹색 인디케이터가 그 탭의 실제 폭·위치로 실제로 옮겨 가고, 다른 분류의 카드는 흐려집니다. 인디케이터 위치는 짐작한 px가 아니라 클릭마다 getBoundingClientRect로 잰 값입니다.

getBoundingClientRecttranslateXclick
function 인디케이터이동(tab) {
  var wrapBox = tab.parentElement.getBoundingClientRect();
  var box = tab.getBoundingClientRect();
  indicator.style.width = box.width + 'px';
  indicator.style.transform = 'translateX(' + (box.left - wrapBox.left) + 'px)';
}
tabs.forEach(function (tab) {
  tab.addEventListener('click', function () {
    root.classList.remove('is-demo');
    tabs.forEach(function (t) { t.setAttribute('aria-selected', 'false'); });
    tab.setAttribute('aria-selected', 'true');
    인디케이터이동(tab);
    var cat = tab.getAttribute('data-cat');
    cards.forEach(function (card) {
      var show = cat === 'all' || card.getAttribute('data-cat') === cat;
      card.classList.toggle('is-hidden', !show);
    });
  });
});

03케이스 상세 블록

"결과 보기"를 누르면 패널이 실제로 펼쳐지며 전환율·제작 기간·트래픽 증가 숫자 세 개가 0에서 목표값까지 올라갑니다. 카드 위 악센트 바는 클릭 여부와 상관없이 항상 스캔 동작을 반복합니다.

requestAnimationFramemax-heightclick
function 카운트업() {
  if (재생됨) return;
  재생됨 = true;
  var 시작 = performance.now();
  var 길이 = 900;
  function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
  function tick(now) {
    var t = Math.min(1, (now - 시작) / 길이);
    var e = easeOut(t);
    nums.forEach(function (el) {
      var target = Number(el.getAttribute('data-target'));
      var suffix = el.getAttribute('data-suffix');
      el.textContent = Math.round(target * e) + suffix;
    });
    if (t < 1) requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
}

04기술 스택 칩

도형으로 그린 기술 칩 여덟 개(실제 로고 아님)가 한 줄로 이어져 끊김 없이 흘러갑니다. 커서를 올리거나 옆 버튼을 눌러 두면 흐름이 그 자리에서 멈춥니다.

translateX(-50%)animation-play-statepointerenter
// 트랙 자체엔 gap 을 쓰지 않는다 — 항목마다 margin-right 로 간격을 준다
.sk__track { display: flex; width: max-content; animation: sk-scroll 13s linear infinite; }
.sk__chip { flex: 0 0 auto; margin-right: $sp-5; }
@keyframes sk-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

05경력 타임라인

세로선 위 경력 네 지점(프로젝트 A 리드·프로젝트 B 참여·프로젝트 C 인턴·전공 수료)을 클릭하면 그 지점까지 진행선이 scaleY로 실제로 채워지고, 위 설명 문단이 그 시점 내용으로 바뀝니다.

scaleYclickaria-current
stops.forEach(function (btn) {
  btn.addEventListener('click', function () {
    root.classList.remove('is-demo');
    var i = Number(btn.getAttribute('data-i'));
    root.setAttribute('data-active', String(i));
    root.querySelector('.et__fill').style.transform = 'scaleY(' + ((i + 1) / stops.length) + ')';
    stops.forEach(function (s) { s.removeAttribute('aria-current'); });
    btn.setAttribute('aria-current', 'step');
    desc.textContent = 설명[i];
  });
});

06추천사 인용

추천사 카드 세 장이 살짝 어긋난 채 겹쳐 쌓여 있습니다. "다음 후기"를 클릭하면 맨 앞 카드가 실제로 맨 뒤로 넘어가며 순서가 바뀌고, 큰 인용부호 장식이 계속 흔들립니다.

transformclickaria-live
function render() {
  cards.forEach(function (card, i) { card.setAttribute('data-order', String(i)); });
  status.textContent = cards[0].querySelector('.pq__who').textContent + ' 후기를 보는 중';
}
next.addEventListener('click', function () {
  root.classList.remove('is-demo');
  cards.push(cards.shift());
  render();
});

07연락 폼

이름·이메일을 넣고 보내기를 누르면 실제로 값을 검사합니다. 빠진 칸은 짧게 흔들리며 포커스를 받고, 통과하면 제출 버튼에 체크가 그려집니다.

focusstroke-dashoffsetsubmit
form.addEventListener('submit', function (e) {
  e.preventDefault();
  var 이름있음 = name.value.trim().length > 0;
  var 이메일유효 = /^\S+@\S+\.\S+$/.test(email.value);
  if (!이름있음) {
    흔들기(name);
    name.focus({ preventScroll: true });
    status.textContent = '이름을 입력해 주세요.';
    return;
  }
  if (!이메일유효) {
    흔들기(email);
    email.focus({ preventScroll: true });
    status.textContent = '올바른 이메일을 입력해 주세요.';
    return;
  }
  form.classList.add('is-done');
  status.textContent = '메시지를 보냈습니다.';
});

08이력서 CTA

"PDF 내려받기"를 누르면 버튼 안 진행바가 실제로 채워진 뒤 화살표 아이콘이 체크로 바뀌고, 잠시 뒤 원래 상태로 되돌아갑니다. 클릭 전에는 화살표가 계속 살짝 튑니다.

scaleXsetTimeoutstroke-dashoffset
btn.addEventListener('click', function () {
  if (진행중) return;
  진행중 = true;
  btn.classList.add('is-loading');
  status.textContent = '준비 중';
  setTimeout(function () {
    btn.classList.remove('is-loading');
    btn.classList.add('is-done');
    status.textContent = '다운로드 완료';
    setTimeout(function () {
      btn.classList.remove('is-done');
      status.textContent = '';
      진행중 = false;
    }, 1600);
  }, 900);
});

09쇼케이스 푸터

이메일 칸에 주소를 넣고 구독을 누르면 정규식으로 실제 형식을 가려냅니다. 올바르면 체크가 그려지며 완료 문구가 뜨고, 틀리면 칸이 좌우로 짧게 떨립니다. 그 아래엔 쇼케이스·연락·팔로우 세 묶음의 링크가 고정돼 있습니다.

submitstroke-dashoffsetclick
form.addEventListener('submit', function (e) {
  e.preventDefault();
  var ok = /^\S+@\S+\.\S+$/.test(input.value);
  if (ok) {
    form.classList.add('is-done');
    status.textContent = '구독 완료 — 새 프로젝트를 메일로 보내드립니다.';
  } else {
    input.classList.remove('is-shake');
    void input.offsetWidth;
    input.classList.add('is-shake');
    status.textContent = '올바른 이메일을 입력해 주세요.';
  }
});

어디서 깨지는가 — 함정

아홉 개를 만드는 동안 실제로 네 번 걸렸습니다. 첫 번째는 프로젝트 그리드(02)였습니다. 썸네일을 44px로 두고 탭 여백도 넉넉하게 잡았더니, 카드 두 줄과 탭 줄을 합친 세로 길이가 480×300 캔버스를 넘어서 렌더 화면에 스크롤바가 생겼습니다. 썸네일을 30px로 줄이고 탭 여백·그리드 간격을 좁히자 스크롤바 없이 한 화면에 들어왔습니다.

두 번째는 케이스 상세 블록(03)이었습니다. 처음엔 "결과 보기" 버튼 둘레에 얇은 테두리 링을 옅게 펄스시켰는데, 프레임 사이 변화량이 작아 23장 중 4장에서만 움직임으로 잡혔습니다. 카드 상단에 굵은 악센트 바를 두고 scaleX로 크게 스캔시키자 17장으로 늘었습니다. 밝기 차이가 작은 장식은 실측값도 작게 나온다는 뜻입니다.

세 번째는 추천사 인용(06)이었습니다. 카드 더미를 담는 박스 높이를 128px로 잡았는데, 실제 카드 내용(큰 인용부호·2줄 문구·이름·안쪽 여백)이 그보다 커서 뒤 카드 문구가 앞 카드 테두리 밖으로 흘러나와 "다음 후기" 버튼과 겹쳐 보였습니다. 박스 높이를 164px로 늘리고 인용부호 크기를 줄이자 겹침이 사라졌습니다.

네 번째는 연락 폼(07)이었습니다. 이 항목은 모든 동작이 제출 이벤트뿐이라 처음엔 상시 재생되는 장식이 하나도 없었고, 그 결과 자동 렌더에서 애니메이션이 전혀 잡히지 않았습니다. 다른 항목처럼 제출 버튼에 은은한 글로우 펄스를 넣고 나서야 움직임이 측정됐습니다. 위 네 함정을 전부 고친 최신 코드는 pcbj2jd9 로 여는 zip 안에 그대로 들어 있습니다.

접근성

모션을 줄이도록 설정한 브라우저(prefers-reduced-motion: reduce)에서는 아홉 개 모두 장식용 움직임만 끄고 결과 상태는 그대로 보여줍니다 — 경력 진행선은 채워진 모습 그대로, 프로젝트 그리드는 걸러진 모습 그대로 남고 케이스 상세의 카운트업 숫자는 정보라서 값을 지우지 않습니다. 프로젝트 그리드(02)는 role="tablist"aria-selected로 탭 상태를 전달하고, 경력 타임라인(05)은 aria-current="step"으로 지금 보는 지점을 알립니다. 추천사(06)는 aria-roledescription="carousel"과 결과 문구용 role="status"를 함께 쓰고, 연락 폼(07)은 이름·이메일이 비었을 때 그 칸으로 focus({ preventScroll: true })를 옮겨 화면을 제자리에 둔 채 포커스만 이동시킵니다. 이 MDN Element.getBoundingClientRect() 문서는 프로젝트 그리드(02)의 인디케이터가 왜 짐작한 px 대신 실측값을 쓰는지 설명합니다.

이 밖에 랜딩·이메일 섹션 템플릿을 더 찾는다면 템플릿 카테고리를 훑어보시고, 데모를 만든 사람이 궁금하면 소개 페이지에서 확인할 수 있습니다.

FAQ

이 템플릿에 실제 프로젝트 사진이나 로고를 써도 되나요?

네, 오히려 그걸 전제로 짰습니다. 썸네일·아바타·워드마크 자리를 실제 파일로 바꿔 넣어도 크기·정렬은 그대로 유지됩니다. 플레이스홀더 도형은 demo-tokens.scss에 정의된 먹·파랑·크림 색만 쓰고, 캔버스마다 다른 무대 배경(옐로·오렌지 등)은 별도 토큰이라 두 색 체계가 겹칠 일이 없습니다.

React 버전은 CSS 버전과 뭐가 다른가요?

동작은 같습니다. 활성 탭·펼침 상태·카드 순서 같은 값은 useState로 관리하고, 변수 3개(duration·easing·color)는 props로 받아 CSS 변수(--duration --easing --color)로 넘깁니다. 프로젝트 그리드(02)의 인디케이터는 useEffectgetBoundingClientRect로 마운트 직후 위치를 한 번 재고, 클릭 때마다 같은 방식으로 다시 잽니다.

연락 폼과 이력서 CTA는 실제로 메일을 보내거나 파일을 받나요?

검증·포커스 이동·진행바·체크 표시는 전부 실제 상태 변화입니다. 다만 두 데모 모두 연결된 실제 서버나 파일이 없어서, 지금 상태로는 메일이 실제로 발송되거나 PDF가 실제로 내려받아지지는 않습니다. 실제로 쓸 때는 연락 폼의 제출 핸들러에 API 호출을, 이력서 CTA의 진행 완료 시점에 실제 href를 연결하면 나머지 검증·애니메이션 로직은 그대로 재사용할 수 있습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.