The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 1 | "use client"; |
| 2 | |
| 3 | import * as React from "react"; |
| 4 | import { motion } from "framer-motion"; |
| 5 | import { cn } from "../lib/utils"; |
| 6 | import { transitions } from "../lib/motion"; |
| 7 | |
| 8 | const SIZES = { |
| 9 | xs: "size-5 text-[9px]", |
| 10 | sm: "size-6 text-[10px]", |
| 11 | md: "size-8 text-xs", |
| 12 | lg: "size-10 text-sm", |
| 13 | } as const; |
| 14 | |
| 15 | /** Stable, soft background per name, so the same person keeps one color. */ |
| 16 | function hueFor(seed: string): number { |
| 17 | let hash = 0; |
| 18 | for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; |
| 19 | return hash % 360; |
| 20 | } |
| 21 | |
| 22 | function initials(name: string): string { |
| 23 | const parts = name.trim().split(/\s+/).filter(Boolean); |
| 24 | if (parts.length === 0) return "?"; |
| 25 | return ((parts[0]?.[0] ?? "") + (parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "")).toUpperCase(); |
| 26 | } |
| 27 | |
| 28 | export interface AvatarProps { |
| 29 | name: string; |
| 30 | src?: string | null; |
| 31 | size?: keyof typeof SIZES; |
| 32 | className?: string; |
| 33 | } |
| 34 | |
| 35 | export function Avatar({ name, src, size = "md", className }: AvatarProps) { |
| 36 | const [failed, setFailed] = React.useState(false); |
| 37 | const hue = hueFor(name); |
| 38 | return ( |
| 39 | <span |
| 40 | data-slot="avatar" |
| 41 | title={name} |
| 42 | className={cn( |
| 43 | "relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full font-semibold ring-2 ring-background", |
| 44 | SIZES[size], |
| 45 | className, |
| 46 | )} |
| 47 | style={{ |
| 48 | background: `oklch(0.92 0.05 ${hue})`, |
| 49 | color: `oklch(0.42 0.1 ${hue})`, |
| 50 | }} |
| 51 | > |
| 52 | {src && !failed ? ( |
| 53 | // eslint-disable-next-line @next/next/no-img-element -- framework-free package |
| 54 | <img src={src} alt="" className="size-full object-cover" onError={() => setFailed(true)} /> |
| 55 | ) : ( |
| 56 | initials(name) |
| 57 | )} |
| 58 | </span> |
| 59 | ); |
| 60 | } |
| 61 | |
| 62 | /** Overlapping avatars that spread apart on hover. */ |
| 63 | export function AvatarGroup({ |
| 64 | people, |
| 65 | max = 4, |
| 66 | size = "sm", |
| 67 | className, |
| 68 | }: { |
| 69 | people: Array<{ name: string; src?: string | null }>; |
| 70 | max?: number; |
| 71 | size?: keyof typeof SIZES; |
| 72 | className?: string; |
| 73 | }) { |
| 74 | const [hovered, setHovered] = React.useState(false); |
| 75 | const visible = people.slice(0, max); |
| 76 | const overflow = people.length - visible.length; |
| 77 | return ( |
| 78 | <div |
| 79 | className={cn("flex items-center", className)} |
| 80 | onMouseEnter={() => setHovered(true)} |
| 81 | onMouseLeave={() => setHovered(false)} |
| 82 | aria-label={people.map((person) => person.name).join(", ")} |
| 83 | > |
| 84 | {visible.map((person, index) => ( |
| 85 | <motion.span |
| 86 | key={`${person.name}-${index}`} |
| 87 | animate={{ marginLeft: index === 0 ? 0 : hovered ? 2 : -8 }} |
| 88 | transition={transitions.spring} |
| 89 | style={{ zIndex: visible.length - index }} |
| 90 | > |
| 91 | <Avatar name={person.name} src={person.src ?? null} size={size} /> |
| 92 | </motion.span> |
| 93 | ))} |
| 94 | {overflow > 0 ? ( |
| 95 | <motion.span |
| 96 | animate={{ marginLeft: hovered ? 2 : -8 }} |
| 97 | transition={transitions.spring} |
| 98 | className={cn( |
| 99 | "relative inline-flex items-center justify-center rounded-full bg-muted font-semibold text-muted-foreground ring-2 ring-background", |
| 100 | SIZES[size], |
| 101 | )} |
| 102 | > |
| 103 | +{overflow} |
| 104 | </motion.span> |
| 105 | ) : null} |
| 106 | </div> |
| 107 | ); |
| 108 | } |
| 109 |