A data-driven presentation system for weekly update slides. Built with Next.js, React, Tailwind CSS, and TypeScript. No PowerPoint — just write a TypeScript object and get a responsive, shareable deck.
nvm use # Node 22 (see .nvmrc)
npm install
npm run dev # http://localhost:3000Visit /sample to see the template deck with all available slide layouts.
Create src/data/decks/<slug>.ts. Copy sample.ts as a starting point.
import type { DeckData } from '@/lib/types';
const deck: DeckData = {
slug: 'wk10-26',
title: 'Week of Mar 6–12, 2026',
start: '20260306',
end: '20260312',
label: 'Weekly Update',
date: '6 Mar, 2026',
project: 'Frontend',
author: 'Your Name',
weekRange: 'Mar 6–12',
slides: [
{ layout: 'title', headline: 'Week 10\nUpdates' },
// ... more slides
{ layout: 'closing', headline: 'See You\nNext Week' },
],
};
export default deck;Add the entry to src/data/entries.ts (newest first):
{ slug: 'wk10-26', title: 'Week of Mar 6–12, 2026', start: '20260306', end: '20260312' },Add the import to src/data/decks/index.ts:
'wk10-26': () => import('./wk10-26'),Put images in public/assets/decks/<slug>/ and reference them in your slide data:
image: { src: '/assets/decks/wk10-26/diagram.png', alt: 'Architecture diagram' }Six layouts are available. Use \n in headline and body strings for line breaks.
Opening slide with a large headline and optional status badges.
{
layout: 'title',
headline: 'Week 10\nUpdates',
highlights: [
{ label: 'Feature A', status: '✓' }, // done
{ label: 'Feature B', status: '→' }, // in progress
{ label: 'Feature C', status: '✗' }, // blocked
{ label: 'Feature D' }, // no status
],
}Headline with 2–3 text columns below. Columns support an optional title, bullet-list body, and download link.
{
layout: 'full-headline',
headline: 'Project\nStatus',
columns: [
{ title: 'Done', body: '• Task one\n• Task two' },
{ title: 'Next', body: '• Task three\n• Task four' },
],
}Long-form text alongside an image. Set reverse: true to put the image on the left.
{
layout: 'content-photo',
headline: 'Deep Dive',
body: 'Explanation text here...',
image: { src: '/assets/decks/wk10-26/photo.png', alt: 'Description', caption: 'Fig 1.' },
reverse: false, // true = image left, text right
}Small image next to the headline, with columns below.
{
layout: 'photo-headline',
headline: 'Feature\nOverview',
image: { src: '/assets/decks/wk10-26/icon.png', alt: 'Icon' },
columns: [
{ title: 'Details', body: '• Point one\n• Point two' },
{ title: 'Notes', body: '• Point one\n• Point two' },
],
}Portrait card for team introductions.
{
layout: 'profile',
headline: 'Jane\nDoe',
body: 'Bio text here...',
image: { src: '/assets/decks/wk10-26/headshot.png', alt: 'Jane Doe' },
}Full-screen headline. No other fields.
{
layout: 'closing',
headline: 'Thanks!\nSee You\nNext Week',
}All styling is driven by Tailwind CSS with custom design tokens. No component-level CSS files — everything is configured through src/app/globals.css and Tailwind classes in the components.
Edit src/app/globals.css to change the deck's visual identity:
@theme {
/* ── Colors ── */
--color-deck-bg: #F0EDEB; /* Slide background */
--color-deck-primary: #000000; /* Headlines, body text, active dots */
--color-deck-secondary: #4A4A4A; /* Captions, status icons, date */
--color-deck-accent: #FFFFFF; /* Button hover text */
--color-deck-dark: #1A1A1A; /* Page background (behind slide) */
--color-deck-track: #D9D5D2; /* Progress bar track, inactive dots, dividers */
--color-deck-disabled: #C0C0C0; /* Disabled nav arrows, inactive lang toggle */
--color-deck-hover-bg: #E8E5E3; /* Hover states */
/* ── Fonts ── */
--font-headline: var(--font-bebas-neue), sans-serif;
--font-body: var(--font-inter), sans-serif;
}These tokens are used as Tailwind utilities throughout the components (e.g. text-deck-primary, bg-deck-bg, font-headline).
Responsive spacing scales down at breakpoints. Edit in globals.css:
:root {
--slide-margin: 80px; /* Padding inside slide edges */
--slide-gutter: 40px; /* Gap between columns */
--slide-radius: 40px; /* Slide border radius */
}
/* Tablet (≤1200px): 48px / 24px / 24px */
/* Mobile (≤768px): 24px / 16px / 16px */Fonts are loaded in src/app/layout.tsx via next/font/google:
| Role | Font | Weights | Used for |
|---|---|---|---|
font-headline |
Bebas Neue | 400 | All headlines (uppercase, tight tracking) |
font-body |
Inter | 400, 500 | Body text, labels, badges, UI chrome |
To swap fonts, edit layout.tsx and update the @theme font variables in globals.css.
┌─────────────────────────────────────────────────────────┐
│ ProgressBar ← gradient bar, 3px, top edge │
├─────────────────────────────────────────────────────────┤
│ Header │
│ ├── label (left) ── deck.label │
│ └── LanguageSwitcher + date (right) │
├─────────────────────────────────────────────────────────┤
│ │
│ SlideRenderer → picks one of: │
│ ┌─ TitleSlide │
│ ├─ FullHeadlineSlide │
│ ├─ ContentPhotoSlide │
│ ├─ PhotoHeadlineSlide │
│ ├─ ProfileSlide │
│ └─ ClosingSlide (hides header, footer, nav arrows) │
│ │
│ ◄ NavArrow (left) NavArrow (right) ► │
├─────────────────────────────────────────────────────────┤
│ Footer │
│ ├── Project ├── Author └── Week │
├─────────────────────────────────────────────────────────┤
│ DotIndicators ← centered, bottom bar │
└─────────────────────────────────────────────────────────┘
Logo (absolute, bottom-left of page, outside slide)
| Component | File | What to customize |
|---|---|---|
| Slide canvas | SlideCarousel.tsx |
Aspect ratio (16/9), border radius, chrome padding |
| Progress bar | ProgressBar.tsx |
Height (3px), gradient colors, shimmer animation speed |
| Nav arrows | SlideCarousel.tsx |
Size (w-10 h-10), border radius, hover/active states |
| Dot indicators | DotIndicators.tsx |
Size (w-2 h-2), gap, active scale (scale-125) |
| Header | SlideCarousel.tsx |
Font size (17px), padding, label content |
| Footer | SlideCarousel.tsx |
Column labels ("Project", "Author", "Week"), font sizes |
| Language switcher | LanguageSwitcher.tsx |
Languages offered, toggle style |
| Logo | Deck.tsx |
Image source, size (h-6), opacity (opacity-60), position |
Each slide type uses clamp() for fluid, viewport-responsive sizing:
| Layout | Font size | Clamp range |
|---|---|---|
title |
Largest | clamp(100px, 14vw, 260px) |
closing |
Extra large | clamp(120px, 16vw, 300px) |
full-headline |
Large | clamp(80px, 11vw, 200px) |
profile |
Medium-large | clamp(70px, 9vw, 160px) |
photo-headline |
Medium | clamp(50px, 7vw, 120px) |
content-photo |
Small | clamp(40px, 5vw, 80px) |
To adjust, edit the style={{ fontSize: 'clamp(...)' }} in each slide component under src/components/slides/.
The animated gradient in ProgressBar.tsx:
backgroundImage: 'linear-gradient(90deg, #E8A87C, #D4798A, #A578C2, #85C1E9, #82E0AA, #E8A87C)'Edit these hex values to change the progress bar color scheme. The shimmer animation (defined in globals.css) scrolls the gradient continuously.
When a slide has layout: 'closing', the carousel hides the header, footer, and nav arrows (via chromeHidden). The slide fills the full canvas with just the headline. This is controlled in Deck.tsx:
chromeHidden={localizedDeck.slides.map(s => s.layout === 'closing')}Add a Chinese translation file at src/data/locales/<slug>.zh.json. It mirrors the deck structure — any field you include overrides the English default.
{
"slides": [
{ "headline": "第10周\n汇报" },
{ "headline": "项目\n状态", "columns": [{ "title": "完成", "body": "• 任务一" }] }
]
}A language switcher (EN / 中文) appears in the header automatically when a locale file exists.
| Path | Description |
|---|---|
/ |
Redirects to /latest |
/latest |
Redirects to the newest deck (first entry in entries.ts) |
/<slug> |
Renders a specific deck |
/archive |
Lists all decks |
- Arrow keys (Left / Right) — previous / next slide
- Home / End — first / last slide
- Click dot indicators to jump to a slide
- Arrow buttons on either side of the slide
| Command | Description |
|---|---|
npm run dev |
Start dev server |
npm run build |
Build static HTML to out/ |
npm run start |
Serve production build |
npm run lint |
Run ESLint |
npm run deploy |
Build + deploy to Surge |
The project exports as static HTML (next.config.ts → output: 'export'). The out/ directory can be deployed to any static host.
The fastest way to deploy — one command, no CI setup needed. See the full Surge docs for more details.
npm install -g surge
surge login # create a free account or log inYou can deploy to any name you want on .surge.sh — it's first-come, first-served. Choose a subdomain that makes sense for your team:
my-team-updates.surge.sh
frontend-weekly.surge.sh
acme-eng-decks.surge.sh
Update the deploy script in package.json with your chosen name:
"deploy": "next build && surge ./out my-team-updates.surge.sh"npm run deployThat's it. Your deck is live at https://my-team-updates.surge.sh.
Each team member can deploy to their own subdomain, or the team can share one. Re-running npm run deploy updates the same URL in place.
To use a custom domain (e.g. decks.yourcompany.com) instead of a .surge.sh subdomain:
- Add a
public/CNAMEfile:decks.yourcompany.com - Add a CNAME DNS record:
decks.yourcompany.com→na-west1.surge.sh - Update the deploy script:
"deploy": "next build && surge ./out decks.yourcompany.com"
Custom domains require a Surge Plus plan. Free accounts can use any .surge.sh subdomain.
| Command | Description |
|---|---|
surge list |
List your deployed projects |
surge teardown my-team-updates.surge.sh |
Remove a deployment |
surge whoami |
Check logged-in account |
Full reference: surge.sh/help
Better for automated deploys on every push. Requires a GitHub repo and Actions setup.
If deploying to https://<user>.github.io/<repo>/ (not a custom domain), set the base path in next.config.ts:
const nextConfig: NextConfig = {
output: 'export',
basePath: '/your-repo-name', // ← add this
images: { unoptimized: true },
};Skip this if using a custom domain (e.g. decks.yourcompany.com).
Create .github/workflows/deploy.yml:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: out
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4Go to Settings → Pages → Source and select GitHub Actions.
To use a custom domain instead of basePath:
- Remove
basePathfromnext.config.ts - Add a
public/CNAMEfile with your domain:decks.yourcompany.com - Configure your DNS to point to GitHub Pages (docs)
Add to package.json:
"deploy:gh": "npm run build && npx gh-pages -d out"Install gh-pages as a dev dependency:
npm install -D gh-pagesRun npm run deploy:gh to push out/ to the gh-pages branch. Then set Settings → Pages → Source to Deploy from a branch → gh-pages.
src/
├── app/ # Next.js routes
│ ├── [slug]/page.tsx # Deck viewer
│ ├── archive/page.tsx # Archive listing
│ ├── latest/page.tsx # Redirect to newest
│ ├── layout.tsx # Root layout + fonts
│ └── globals.css # Tailwind + design tokens
├── components/
│ ├── deck/ # Carousel, nav, progress bar
│ └── slides/ # One component per layout
├── data/
│ ├── entries.ts # Deck registry
│ ├── decks/ # Deck data files + loader
│ └── locales/ # Translation JSON files
├── hooks/
│ └── useSlideNavigation.ts # Keyboard + state logic
└── lib/
├── types.ts # TypeScript interfaces
├── lang.tsx # i18n context + helpers
├── renderHeadline.tsx # \n → <br /> for headlines
└── renderBody.tsx # \n → <br /> for body text
public/assets/decks/ # Images & downloads per deck