no. 22 · projects
Project accordion
Names only; open one for details, tags and a preview.
Preview
Install
npx shadcn@latest add https://curio-components.vercel.app/r/project-accordion.jsonFirst curio component? Also add @import "./curio.css"; to your global CSS. See installation.
Usage
import { ProjectAccordion } from '@/components/curio/projects/project-accordion'
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 <ProjectAccordion items={projects} />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | Project[] | - | Name, description, url, year, tags?, image?, status? |
| defaultOpen | number | null | 0 | Which item starts open. |
Source
This is exactly what the command copies into your project.
components/curio/projects/project-accordion.tsx
'use client'
import { useState } from 'react'
import type { Project } from '../lib/types'
import { Arrow, external, ProjectTags, ProjectThumb } from './project-parts'
// names only; open one to see the details, tags and a preview
export function ProjectAccordion({ items, defaultOpen = 0 }: { items: Project[]; defaultOpen?: number | null }) {
const [open, setOpen] = useState<number | null>(defaultOpen)
return (
<div className="border-t" style={{ borderColor: 'var(--curio-border)' }}>
{items.map((p, i) => {
const isOpen = open === i
return (
<div key={p.name} className="border-b" style={{ borderColor: 'var(--curio-border)' }}>
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full cursor-pointer items-center gap-3 py-3 text-left text-[15px] transition-colors hover:text-[var(--curio-accent)]"
style={{ color: 'var(--curio-fg)' }}
>
<span className="text-xs" style={{ color: 'var(--curio-muted)' }}>
{p.year}
</span>
<span>{p.name}</span>
<span className="ml-auto text-base transition-transform duration-300" style={{ transform: isOpen ? 'rotate(45deg)' : 'none' }}>
+
</span>
</button>
<div className="grid transition-[grid-template-rows] duration-300 ease-out" style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}>
<div className="overflow-hidden">
<div className="flex flex-col gap-4 pb-5 sm:flex-row">
<ProjectThumb project={p} className="h-[110px] w-full shrink-0 rounded-md border sm:w-[42%]" />
<div className="min-w-0">
<p className="text-[14px] leading-relaxed" style={{ color: 'var(--curio-muted)' }}>
{p.description}
</p>
<ProjectTags project={p} className="mt-3" />
<a {...external(p)} className="curio-chip mt-3 text-xs">
Open <Arrow />
</a>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)
}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>
)
}