no. 20 · projects
Project sentence
Projects written into a sentence; hover a name for a card.
Preview
Things I’ve built: LumenLA reading app that turns long articles into calm, paginated chapters.2026 · Next.js · Postgres, TidepoolTA realtime collaborative whiteboard built on CRDTs and websockets.2026 · React · Yjs, PocketlogPA CLI that logs what you worked on, in ten seconds a day.2025 · Rust · CLI and HarborHA tiny self-hosted status page for side projects.2024 · Go · SQLite.
Install
npx shadcn@latest add https://curio-components.vercel.app/r/project-sentence.jsonFirst curio component? Also add @import "./curio.css"; to your global CSS. See installation.
Usage
import { ProjectSentence } from '@/components/curio/projects/project-sentence'
const projects = [
{ name: 'lumen', description: 'A calm reading app.', url: 'https://...', year: '2026', tags: ['next.js'] },
{ name: 'pocketlog', description: 'A cli for daily logs.', url: 'https://...', year: '2025', tags: ['rust'] },
]
export function Work() {
return <ProjectSentence items={projects} />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | Project[] | - | Name, description, url, year, tags?, image?, status? |
| lead | string | "things i’ve built:" | Text before the list. |
Source
This is exactly what the command copies into your project.
components/curio/projects/project-sentence.tsx
import { Fragment } from 'react'
import type { Project } from '../lib/types'
import { external, ProjectThumb } from './project-parts'
// projects written into a sentence; hover a name for a card with the details
export function ProjectSentence({ items, lead = 'Things I’ve built:' }: { items: Project[]; lead?: string }) {
return (
<p className="text-[15px] leading-[2]" style={{ color: 'var(--curio-fg)' }}>
{lead}{' '}
{items.map((p, i) => (
<Fragment key={p.name}>
<a {...external(p)} className="curio-chip group relative">
{p.name}
<span
className="pointer-events-none absolute bottom-[calc(100%+8px)] left-1/2 z-30 w-[240px] -translate-x-1/2 translate-y-1 rounded-lg border p-2 text-left opacity-0 shadow-[0_18px_40px_-12px_rgba(0,0,0,0.3)] transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
style={{ background: 'var(--curio-surface)', borderColor: 'var(--curio-border)', color: 'var(--curio-fg)', whiteSpace: 'normal' }}
>
<ProjectThumb project={p} className="block h-[120px] w-full" />
<span className="mt-2 block text-[13px] leading-snug">{p.description}</span>
<span className="mt-1.5 block text-[11px]" style={{ color: 'var(--curio-muted)' }}>
{[p.year, ...(p.tags ?? [])].join(' · ')}
</span>
</span>
</a>
{i < items.length - 2 ? ', ' : i === items.length - 2 ? ' and ' : '.'}
</Fragment>
))}
</p>
)
}components/curio/projects/project-parts.tsx
import type { Project } from '../lib/types'
export function ProjectThumb({ project: p, className = '' }: { project: Project; className?: string }) {
if (p.image) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={p.image} alt="" className={`object-cover object-top ${className}`} />
}
return (
<span
className={`flex items-center justify-center text-[28px] font-medium ${className}`}
style={{ background: 'var(--curio-accent)', color: 'var(--curio-on-accent)' }}
>
{p.name[0]}
</span>
)
}
export function ProjectTags({ project: p, className = '' }: { project: Project; className?: string }) {
if (!p.tags?.length) return null
return (
<span className={`flex flex-wrap gap-1.5 text-[11px] ${className}`} style={{ color: 'var(--curio-muted)' }}>
{p.tags.map((t) => (
<span key={t} className="rounded-md border px-1.5 py-px" style={{ borderColor: 'var(--curio-border)' }}>
{t}
</span>
))}
</span>
)
}
export const external = (p: Project) => ({ href: p.url, target: '_blank', rel: 'noopener noreferrer' })
export function Arrow() {
return (
<svg viewBox="0 0 10 10" width={10} height={10} fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<path d="M2.5 7.5 7.5 2.5M3.5 2.5h4v4" />
</svg>
)
}