curio.
no. 22 · projects

Project accordion

Names only; open one for details, tags and a preview.

Preview
L

A reading app that turns long articles into calm, paginated chapters.

Next.jsPostgresOpen
T

A realtime collaborative whiteboard built on CRDTs and websockets.

ReactYjsOpen
P

A CLI that logs what you worked on, in ten seconds a day.

RustCLIOpen

Install

npx shadcn@latest add https://curio-components.vercel.app/r/project-accordion.json

First 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

PropTypeDefaultDescription
itemsProject[]-Name, description, url, year, tags?, image?, status?
defaultOpennumber | null0Which 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>
  )
}