curio.
no. 20 · projects

Project sentence

Projects written into a sentence; hover a name for a card.

Install

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

First 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

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