A production-quality marketing site for a fictional product analytics platform, built to show how I approach landing pages: real components, real accessibility, and charts built from scratch rather than dropped in from a library.
Live site: https://sightline-analytics.vercel.app
Sightline is not a real company. The product, the customer names, the quotes and every number on the page are invented. This is a self-directed portfolio project.
Take the kind of request that lands in my inbox most weeks — "a modern, responsive landing page for our SaaS, React or Next.js preferred" — and answer it properly rather than minimally.
That meant treating four things as non-negotiable:
- The charts had to be real. An analytics product with fake chart images is the tell that nobody built the hard part.
- Both themes had to be designed, not generated by inverting the light one.
- Keyboard and screen-reader users had to get the whole page, including the tabs, the accordion and the data behind every chart.
- It had to be fast, because performance is the one quality claim a client can verify in thirty seconds.
| Choice | Reason |
|---|---|
| Next.js 16 (App Router) | Static prerendering out of the box. The whole page ships as HTML, so first paint does not wait on JavaScript. |
| TypeScript | Chart geometry is arithmetic. Types catch the off-by-one before the browser does. |
| Tailwind CSS v4 | Utilities for layout, CSS custom properties for colour. See the theming note below. |
| Framer Motion | Scroll-triggered reveals and layout transitions with a built-in reduced-motion escape hatch. |
| No chart library | Recharts or Chart.js would have been faster to write and heavier to ship. The SVG here is a few hundred lines and does exactly what this page needs. |
Charts built from nothing — src/components/charts/
Three chart types, all hand-drawn SVG: an area chart with a damped Catmull-Rom curve, a grouped bar chart with rounded data-ends anchored flat to the baseline, and a sparkline. Each has a pointer-tracking crosshair that snaps to the nearest real data point, a tooltip, a legend, and a View data table disclosure exposing the underlying numbers. Axis ceilings round to readable values, so 3,680 gives a 4,000 axis rather than 3,680.
Theming with no dark: variants — src/app/globals.css
Every colour in the UI is a semantic role (--surface, --ink-2, --line, --s1) rather than a literal. Light and dark are two selected sets of the same roles, defined once. Components never carry a dark-mode variant, which is the same pattern I maintain in a bank-wide design system at work. An inline script applies the stored theme before first paint, and the toggle reads through useSyncExternalStore so there is no flash and no hydration mismatch.
Accessibility that survives a real audit
Tabs implement the full roving-focus pattern with arrow, Home and End keys. The accordion is a real disclosure with aria-expanded and labelled regions. Charts carry a table view. There is a skip link, visible focus rings throughout, and semantic landmarks.
Reduced motion done correctly — src/lib/motion.ts
Every reveal animates opacity alongside its transform. The usual scroll-animation bug is a transform-only reveal that leaves the page permanently blank when the viewer has reduced motion enabled. Here the parallax is disabled, the counters resolve to their final value, and all content ends up visible.
Measured with Lighthouse against a production build (next start), not the dev server, on a desktop profile with Lighthouse's default throttling.
| Category | Score |
|---|---|
| Performance | 97 |
| Accessibility | 100 |
| Best Practices | 100 |
| SEO | 100 |
| Metric | Value |
|---|---|
| First Contentful Paint | 0.8 s |
| Speed Index | 0.8 s |
| Largest Contentful Paint | 2.5 s |
| Total Blocking Time | 40 ms |
| Cumulative Layout Shift | 0 |
Cumulative Layout Shift is zero because the charts reserve their height before measuring, and the theme is applied by an inline script before first paint.
The full report is committed at docs/lighthouse.json, so the numbers above can be checked rather than taken on trust.
![]() |
![]() |
![]() |
![]() |
npm install
npm run devThen open http://localhost:3000.
npm run build && npm start # production build
npx tsc --noEmit # type check
npx eslint . # lintsrc/
├── app/ layout, page, design tokens
├── components/
│ ├── charts/ SVG charts and shared scale helpers
│ ├── sections/ one file per page section
│ └── ui/ Button, Icon, Section, ThemeToggle
└── lib/
├── content.ts every string, price and data point
└── motion.ts shared animation vocabulary
All copy and data lives in content.ts rather than being scattered through JSX, so the page can be rebranded without touching a component.
MIT — see LICENSE.
Built by Irakli Tabukashvili. Available for landing page and front-end work on Upwork.




