Portfolio
Present a profile, project details and contact links.
Template preview
template-portfolioIndependent practice / Seoul
Sora Han
Product designer and front-end developer.
I design scheduling, reporting and inventory tools.
My work covers the screen layout, interaction prototype and React implementation.
Selected work
Dispatch queueProduct design · React2026
A queue for assigning changed delivery routes before the morning shift.
- The challenge
- Route changes and driver assignments arrived in separate messages.
- The approach
- Put the route, assigned driver and unresolved stops in the same row.
- Delivered
- Queue, route detail and reassignment form.
- The outcome
- The prototype keeps an unassigned route visible until a dispatcher selects a driver.
Month-end reviewProduct design2025
A monthly report with the source entries next to each account total.
- The challenge
- Reviewers had to leave the report to check an unexplained balance.
- The approach
- Link each account row to its entries and keep the comparison period visible.
- Delivered
- Account table, period selector and entry detail.
- The outcome
- The prototype shows which entries make up a total and which still need review.
Equipment directoryWeb design · React2025
A searchable inventory of shared cameras, microphones and lighting.
- The challenge
- Availability was recorded in a spreadsheet separate from the equipment list.
- The approach
- Show availability with each item and group equipment by its use.
- Delivered
- Inventory, category filters and equipment detail.
- The outcome
- The prototype distinguishes available equipment from checked-out items.
Contact
Fictional profile and project briefs for this example.
Install
Set up the base theme and Pretendard once using the installation guide. Adding these templates does not overwrite your theme, global CSS or page routes.
npx shadcn@latest add @neumorphism-ui/template-portfolioUsage
import { Portfolio, type PortfolioProfile, type PortfolioProject } from "@/components/blocks/portfolio";
export function Example({ profile, projects }: {
profile: PortfolioProfile;
projects: PortfolioProject[];
}) {
return <Portfolio profile={profile} projects={projects} locale="en" />;
}Connect your application
Supply profile and projects from your application. The template is static, requires no client state, and exposes project details through native details elements.
Installed source
@components/blocks/portfolio.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
export type PortfolioLocale = "ko" | "en" | "ja" | "zh";
export type PortfolioProfile = {
name: string;
role: string;
location?: string;
intro: readonly string[];
email?: string;
github?: string;
linkedin?: string;
};
export type PortfolioProject = {
title: string;
type: string;
year: string;
summary: string;
challenge: string;
approach: string;
deliverables: string;
outcome: string;
};
export interface PortfolioProps {
profile: PortfolioProfile;
projects: readonly PortfolioProject[];
locale?: PortfolioLocale;
className?: string;
}
const copy = {
ko: {
practice: "독립 작업",
work: "선정 작업",
challenge: "문제",
approach: "접근",
delivered: "제공한 결과",
outcome: "성과",
contact: "연락",
email: "이메일",
back: "맨 위로",
newTab: "새 탭에서 열림",
},
en: {
practice: "Independent practice",
work: "Selected work",
challenge: "The challenge",
approach: "The approach",
delivered: "Delivered",
outcome: "The outcome",
contact: "Contact",
email: "Email",
back: "Back to top",
newTab: "Opens in a new tab",
},
ja: {
practice: "独立した仕事",
work: "主な実績",
challenge: "課題",
approach: "アプローチ",
delivered: "提供したもの",
outcome: "成果",
contact: "連絡先",
email: "メール",
back: "先頭へ",
newTab: "新しいタブで開きます",
},
zh: {
practice: "独立实践",
work: "精选项目",
challenge: "问题",
approach: "方法",
delivered: "交付内容",
outcome: "成果",
contact: "联系",
email: "邮箱",
back: "返回顶部",
newTab: "在新标签页打开",
},
} as const;
export function Portfolio({
profile,
projects,
locale = "en",
className,
}: PortfolioProps) {
const t = copy[locale];
return (
<div
id="portfolio-top"
data-slot="portfolio"
className={cn("mx-auto w-full max-w-4xl text-[var(--foreground)]", className)}
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-[var(--border)] py-4">
<a href="#portfolio-top" className="rounded-md text-sm font-semibold no-underline outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]">
{profile.name}
</a>
<nav aria-label={t.contact} className="flex items-center gap-4 text-sm">
<a href="#portfolio-work" className="hover:underline">{t.work}</a>
<a href="#portfolio-contact" className="hover:underline">{t.contact}</a>
</nav>
</header>
<main className="grid gap-10 py-8 sm:py-10">
<section aria-labelledby="portfolio-title" className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--muted-foreground)]">
{t.practice}{profile.location ? " / " + profile.location : ""}
</p>
<h2 id="portfolio-title" className="text-4xl font-semibold tracking-tight sm:text-5xl">{profile.name}</h2>
<p className="text-lg font-semibold sm:text-xl">{profile.role}</p>
<div className="grid max-w-2xl gap-2 text-sm leading-relaxed text-[var(--muted-foreground)] sm:text-base">
{profile.intro.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</section>
<section id="portfolio-work" aria-labelledby="portfolio-work-title" className="scroll-mt-8">
<h3 id="portfolio-work-title" className="text-xl font-semibold">{t.work}</h3>
<div className="mt-4 grid gap-4">
{projects.map((project) => (
<details
key={project.title + ":" + project.year}
className="group overflow-hidden rounded-[var(--neu-radius-surface)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] [box-shadow:var(--neu-shadow-raised-sm)]"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ring)] [&::-webkit-details-marker]:hidden">
<span className="min-w-0 break-words">
<span className="block font-semibold">{project.title}</span>
<span className="mt-1 block text-sm text-[var(--muted-foreground)]">{project.type}</span>
</span>
<span className="flex shrink-0 items-center gap-3 text-sm text-[var(--muted-foreground)]">
{project.year}
<svg aria-hidden="true" className="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M5 12h14" />
<path d="M12 5v14" className="origin-center transition-transform group-open:scale-y-0 motion-reduce:transition-none" />
</svg>
</span>
</summary>
<div className="border-t border-[var(--border)]">
<p className="px-5 py-4 text-sm leading-relaxed text-[var(--muted-foreground)]">{project.summary}</p>
<dl className="grid gap-4 bg-[var(--neu-surface-soft)] p-5 sm:grid-cols-2">
{[
[t.challenge, project.challenge],
[t.approach, project.approach],
[t.delivered, project.deliverables],
[t.outcome, project.outcome],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-semibold uppercase tracking-[0.12em]">{label}</dt>
<dd className="mt-2 text-sm leading-relaxed text-[var(--muted-foreground)]">{value}</dd>
</div>
))}
</dl>
</div>
</details>
))}
</div>
</section>
<section id="portfolio-contact" aria-labelledby="portfolio-contact-title" className="grid gap-4 border-t border-[var(--border)] pt-5 text-sm">
<h3 id="portfolio-contact-title" className="text-xl font-semibold">{t.contact}</h3>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
{profile.email ? (
<a href={"mailto:" + profile.email} className="rounded-[var(--neu-radius-control)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] px-4 py-2 font-semibold no-underline [box-shadow:var(--neu-shadow-raised-sm)] outline-none hover:[box-shadow:var(--neu-shadow-hover)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] active:[box-shadow:var(--neu-shadow-inset)]">{t.email}</a>
) : null}
{profile.github ? (
<a href={profile.github} target="_blank" rel="noreferrer" className="hover:underline">
GitHub <svg aria-hidden="true" className="inline-block size-4 align-text-bottom" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 18 18 6M8 6h10v10" /></svg><span className="sr-only">{t.newTab}</span>
</a>
) : null}
{profile.linkedin ? (
<a href={profile.linkedin} target="_blank" rel="noreferrer" className="hover:underline">
LinkedIn <svg aria-hidden="true" className="inline-block size-4 align-text-bottom" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 18 18 6M8 6h10v10" /></svg><span className="sr-only">{t.newTab}</span>
</a>
) : null}
</div>
</section>
</main>
<footer className="flex items-center justify-between gap-4 border-t border-[var(--border)] py-4 text-xs text-[var(--muted-foreground)]">
<p>© 2026 {profile.name}</p>
<a href="#portfolio-top" className="hover:underline">{t.back}</a>
</footer>
</div>
);
}
const exampleContent = {
"en": {
"profile": {
"name": "Sora Han",
"role": "Product designer and front-end developer.",
"location": "Seoul",
"intro": [
"I design scheduling, reporting and inventory tools.",
"My work covers the screen layout, interaction prototype and React implementation."
],
"email": "hello@example.com"
},
"projects": [
{
"title": "Dispatch queue",
"type": "Product design · React",
"year": "2026",
"summary": "A queue for assigning changed delivery routes before the morning shift.",
"challenge": "Route changes and driver assignments arrived in separate messages.",
"approach": "Put the route, assigned driver and unresolved stops in the same row.",
"deliverables": "Queue, route detail and reassignment form.",
"outcome": "The prototype keeps an unassigned route visible until a dispatcher selects a driver."
},
{
"title": "Month-end review",
"type": "Product design",
"year": "2025",
"summary": "A monthly report with the source entries next to each account total.",
"challenge": "Reviewers had to leave the report to check an unexplained balance.",
"approach": "Link each account row to its entries and keep the comparison period visible.",
"deliverables": "Account table, period selector and entry detail.",
"outcome": "The prototype shows which entries make up a total and which still need review."
},
{
"title": "Equipment directory",
"type": "Web design · React",
"year": "2025",
"summary": "A searchable inventory of shared cameras, microphones and lighting.",
"challenge": "Availability was recorded in a spreadsheet separate from the equipment list.",
"approach": "Show availability with each item and group equipment by its use.",
"deliverables": "Inventory, category filters and equipment detail.",
"outcome": "The prototype distinguishes available equipment from checked-out items."
}
],
"note": "Fictional profile and project briefs for this example."
},
"ko": {
"profile": {
"name": "Sora Han",
"role": "제품 디자이너 · 프런트엔드 개발자",
"location": "서울",
"intro": [
"일정 관리, 보고서, 재고 관리 도구를 디자인합니다.",
"화면 설계부터 인터랙션 시제품과 React 구현까지 작업합니다."
],
"email": "hello@example.com"
},
"projects": [
{
"title": "배차 대기 목록",
"type": "제품 디자인 · React",
"year": "2026",
"summary": "아침 근무 전에 변경된 배송 경로에 기사를 배정하는 화면입니다.",
"challenge": "경로 변경과 기사 배정이 서로 다른 메시지로 전달됐습니다.",
"approach": "경로, 담당 기사, 미해결 배송지를 같은 행에 배치했습니다.",
"deliverables": "대기 목록, 경로 상세, 기사 재배정 폼.",
"outcome": "시제품에서는 기사를 선택할 때까지 미배정 경로가 목록에 남습니다."
},
{
"title": "월말 계정 검토",
"type": "제품 디자인",
"year": "2025",
"summary": "계정별 합계 옆에서 원본 거래 내역을 확인하는 월간 보고서입니다.",
"challenge": "설명되지 않은 잔액을 확인하려면 보고서를 벗어나야 했습니다.",
"approach": "계정 행과 거래 내역을 연결하고 비교 기간을 표시했습니다.",
"deliverables": "계정 표, 기간 선택, 거래 상세.",
"outcome": "시제품에서 합계에 포함된 거래와 검토가 필요한 항목을 구별할 수 있습니다."
},
{
"title": "공용 장비 목록",
"type": "웹 디자인 · React",
"year": "2025",
"summary": "카메라, 마이크, 조명의 대여 가능 여부를 검색하는 목록입니다.",
"challenge": "대여 현황과 장비 목록이 별도 문서에 기록돼 있었습니다.",
"approach": "각 장비 옆에 대여 상태를 표시하고 용도별로 분류했습니다.",
"deliverables": "장비 목록, 분류 필터, 장비 상세.",
"outcome": "시제품에서 대여 가능한 장비와 사용 중인 장비를 구별할 수 있습니다."
}
],
"note": "이 예제의 인물과 프로젝트는 가상의 사례입니다."
},
"ja": {
"profile": {
"name": "Sora Han",
"role": "プロダクトデザイナー · フロントエンド開発者",
"location": "ソウル",
"intro": [
"予定管理、レポート、在庫管理のツールをデザインしています。",
"画面設計から操作の試作、Reactでの実装まで担当します。"
],
"email": "hello@example.com"
},
"projects": [
{
"title": "配車待ち一覧",
"type": "プロダクトデザイン · React",
"year": "2026",
"summary": "朝のシフト前に、変更された配送ルートへ担当者を割り当てる画面です。",
"challenge": "ルート変更と担当者の割り当てが別々のメッセージで届いていました。",
"approach": "ルート、担当者、未解決の配送先を同じ行に配置しました。",
"deliverables": "待ち一覧、ルート詳細、担当者の変更フォーム。",
"outcome": "試作画面では、担当者を選ぶまで未割り当てのルートが一覧に残ります。"
},
{
"title": "月次決算の確認",
"type": "プロダクトデザイン",
"year": "2025",
"summary": "勘定ごとの合計と、その元となる明細を確認する月次レポートです。",
"challenge": "不明な残高を調べるには、レポートを離れる必要がありました。",
"approach": "勘定の行と明細をつなぎ、比較対象の期間を表示しました。",
"deliverables": "勘定一覧、期間選択、明細画面。",
"outcome": "試作画面で、合計に含まれる明細と未確認の項目を区別できます。"
},
{
"title": "共有機材の一覧",
"type": "Webデザイン · React",
"year": "2025",
"summary": "カメラ、マイク、照明の貸出状況を検索する一覧です。",
"challenge": "貸出状況と機材一覧は、別々の文書に記録されていました。",
"approach": "各機材の横に貸出状況を表示し、用途で分類しました。",
"deliverables": "機材一覧、分類フィルター、機材詳細。",
"outcome": "試作画面で、利用可能な機材と貸出中の機材を区別できます。"
}
],
"note": "この例の人物とプロジェクトは架空のものです。"
},
"zh": {
"profile": {
"name": "Sora Han",
"role": "产品设计师 · 前端开发者",
"location": "首尔",
"intro": [
"我设计排期、报表和库存管理工具。",
"工作包括界面布局、交互原型与 React 实现。"
],
"email": "hello@example.com"
},
"projects": [
{
"title": "配送调度列表",
"type": "产品设计 · React",
"year": "2026",
"summary": "在早班开始前,为变更后的配送路线分配司机的界面。",
"challenge": "路线变更与司机分配通过不同的消息传达。",
"approach": "将路线、司机和待处理站点放在同一行。",
"deliverables": "待分配列表、路线详情与重新分配表单。",
"outcome": "在原型中,未分配的路线会保留在列表中,直到调度员选择司机。"
},
{
"title": "月末账目核对",
"type": "产品设计",
"year": "2025",
"summary": "在账户合计旁查看原始交易记录的月度报表。",
"challenge": "核对不明余额时,审核人员必须离开报表。",
"approach": "将账户行与交易记录关联,并显示比较期间。",
"deliverables": "账户表、期间选择与交易详情。",
"outcome": "原型区分了计入合计的交易和仍待核对的项目。"
},
{
"title": "共享设备目录",
"type": "网页设计 · React",
"year": "2025",
"summary": "可查询相机、麦克风和灯具借用状态的目录。",
"challenge": "设备目录和借用状态分别记录在不同文档中。",
"approach": "在每件设备旁显示借用状态,并按用途分类。",
"deliverables": "设备列表、类别筛选与设备详情。",
"outcome": "原型明确区分可借用设备和已借出的设备。"
}
],
"note": "此示例中的人物和项目均为虚构。"
}
} satisfies Record<PortfolioLocale, { profile: PortfolioProfile; projects: PortfolioProject[]; note: string }>;
export function PortfolioExample({ locale = "en" }: { locale?: PortfolioLocale }) {
const sample = exampleContent[locale];
return (
<div className="grid gap-4">
<Portfolio locale={locale} profile={sample.profile} projects={sample.projects} />
<p className="text-xs leading-relaxed text-[var(--muted-foreground)]">{sample.note}</p>
</div>
);
}