会话列表
New侧栏会话列表:带操作槽的分组标题、30px 行(未读圆点、时间与悬停操作钮互换),以及用共享布局在行间平滑滑动的选中底块。
"use client";
import { FileText, MessageSquare, Pin, Plus, Trash2 } from "lucide-react";
import { type ComponentType, useState } from "react";
import {
ThreadList,
ThreadListAction,
ThreadListItem,
ThreadListSection,
} from "@/components/motion/thread-list";
interface PinnedItem {
id: string;
title: string;
icon: ComponentType<{ className?: string }>;
unread?: boolean;
}
interface RecentItem {
id: string;
title: string;
meta: string;
}
const PINNED_ITEMS: PinnedItem[] = [
{ id: "pinned-1", title: "Draft launch checklist", icon: MessageSquare, unread: true },
{ id: "pinned-2", title: "Q3 roadmap notes", icon: FileText },
];
const RECENT_ITEMS: RecentItem[] = [
{ id: "recent-1", title: "Refactor auth middleware", meta: "2m" },
{ id: "recent-2", title: "Fix flaky CI on macOS runners", meta: "1h" },
{ id: "recent-3", title: "Update onboarding docs", meta: "3h" },
{ id: "recent-4", title: "Investigate memory leak in worker pool", meta: "1d" },
{ id: "recent-5", title: "Migrate billing service to v2 API", meta: "3d" },
{ id: "recent-6", title: "Clean up unused feature flags", meta: "1w" },
];
/**
* Sidebar-panel mockup — a pinned section (icons, one unread) and a recent
* section (relative timestamps that swap for pin/delete actions on hover)
* inside a hairline-ringed card that stands in for a real app's sidebar
* surface. Clicking any row moves `activeId`, so the acceptance signal —
* the active pill gliding between rows via `layoutId="active"` — is easy to
* see; hovering any recent row swaps its timestamp for the action buttons.
*/
export function ThreadListPreview() {
const [activeId, setActiveId] = useState<string>(RECENT_ITEMS[0].id);
return (
<div className="flex h-[520px] w-full items-center justify-center rounded-xl border bg-neutral-100 dark:bg-neutral-950">
<div
style={{ boxShadow: "0 0 0 0.5px var(--tl-hairline)" }}
className="w-[280px] rounded-2xl bg-white p-2 dark:bg-neutral-900 [--tl-hairline:rgba(0,0,0,0.08)] dark:[--tl-hairline:rgba(255,255,255,0.157)]"
>
<ThreadList>
<ThreadListSection title="Pinned">
{PINNED_ITEMS.map((item) => (
<ThreadListItem
key={item.id}
active={activeId === item.id}
unread={item.unread}
icon={<item.icon className="h-4 w-4" />}
onSelect={() => setActiveId(item.id)}
>
{item.title}
</ThreadListItem>
))}
</ThreadListSection>
<ThreadListSection
title="Recent"
action={
<ThreadListAction aria-label="New thread" onClick={() => {}}>
<Plus className="h-3 w-3" />
</ThreadListAction>
}
>
{RECENT_ITEMS.map((item) => (
<ThreadListItem
key={item.id}
active={activeId === item.id}
meta={item.meta}
onSelect={() => setActiveId(item.id)}
actions={
<>
<ThreadListAction aria-label="Pin thread" onClick={() => {}}>
<Pin className="h-3 w-3" />
</ThreadListAction>
<ThreadListAction aria-label="Delete thread" onClick={() => {}}>
<Trash2 className="h-3 w-3" />
</ThreadListAction>
</>
}
>
{item.title}
</ThreadListItem>
))}
</ThreadListSection>
</ThreadList>
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/thread-list
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useId } from "react";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ThreadListProps {
className?: string;
children?: ReactNode;
}
/**
* Sidebar conversation/task list root — a tight `flex flex-col` stack of
* `ThreadListSection`s. Wraps `children` in a `LayoutGroup` scoped to this
* instance (via `useId`), so the active-row indicator's `layoutId="active"`
* animation (see `ThreadListItem`) stays local to this list — two
* `ThreadList`s rendered on the same page never fight over which one owns
* the moving highlight.
*/
export function ThreadList({ className, children }: ThreadListProps) {
const groupId = useId();
return (
<LayoutGroup id={groupId}>
<div className={cn("flex flex-col gap-0.5", className)}>{children}</div>
</LayoutGroup>
);
}
export interface ThreadListSectionProps {
/** Muted section label, e.g. "Pinned" or "Recent". */
title?: ReactNode;
/** Trailing slot beside the title, e.g. a "new" `ThreadListAction`. */
action?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Named group of rows inside a `ThreadList`. The header row (label plus an
* optional trailing action) only renders when `title` or `action` is
* passed, so a section can also be used as a bare, unlabeled row group.
*/
export function ThreadListSection({ title, action, className, children }: ThreadListSectionProps) {
return (
<div className={cn("flex flex-col", className)}>
{title !== undefined || action !== undefined ? (
<div className="flex items-center justify-between px-2 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground">{title}</span>
{action}
</div>
) : null}
{children}
</div>
);
}
export interface ThreadListItemProps {
/** Highlights the row and pins the moving indicator here. */
active?: boolean;
/** Renders a small blue dot after the title. */
unread?: boolean;
/** 16px leading icon, e.g. `<MessageSquare className="h-4 w-4" />`. */
icon?: ReactNode;
/** Trailing meta, e.g. a relative timestamp. Hidden on row hover in favor of `actions` when both are passed; stays visible when `actions` is omitted. */
meta?: ReactNode;
/** Row of `ThreadListAction`s revealed on hover, replacing `meta`. */
actions?: ReactNode;
onSelect?: () => void;
className?: string;
/** The row's title — truncates to a single line. */
children?: ReactNode;
}
/**
* One row in a `ThreadList` — a conversation or task entry. The signature
* motion is the active indicator: a `layoutId="active"` pill that glides
* between rows as selection moves (`SPRING_PANEL`), scoped to the enclosing
* `ThreadList`'s `LayoutGroup` so unrelated lists sharing a page don't
* collide. Reduced motion swaps the animated pill for a static span.
*
* Deliberately *not* a single `<button>` wrapping everything: `actions`
* renders real `ThreadListAction` buttons (pin, delete, ...), and a
* `<button>` cannot legally contain another `<button>`. Instead the row is a
* plain container with the selectable button filling it, and — only when
* `actions` is passed — a sibling, absolutely-positioned action cluster
* layered on top at the trailing edge, shown on hover in place of `meta`.
* Both stay real, independently focusable buttons with no nested-control
* hit-testing tricks required.
*/
export function ThreadListItem({
active = false,
unread = false,
icon,
meta,
actions,
onSelect,
className,
children,
}: ThreadListItemProps) {
const reduce = useReducedMotion() ?? false;
const hasMeta = meta !== undefined && meta !== null;
const hasActions = actions !== undefined && actions !== null;
return (
<div className={cn("group relative flex h-[30px] w-full", className)}>
<button
type="button"
onClick={onSelect}
aria-current={active || undefined}
className={cn(
"relative flex w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-sm",
!active && "hover:bg-[var(--wb-hover-subtle)]",
)}
>
{active ? (
reduce ? (
<span aria-hidden className="absolute inset-0 rounded-lg bg-[var(--wb-inset-strong)]" />
) : (
<motion.span
aria-hidden
layoutId="active"
className="absolute inset-0 rounded-lg bg-[var(--wb-inset-strong)]"
transition={SPRING_PANEL}
/>
)
) : null}
{icon ? (
<span className="relative z-10 flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span
className={cn(
"relative z-10 min-w-0 flex-1 truncate",
active ? "text-foreground" : "text-muted-foreground group-hover:text-foreground",
)}
>
{children}
</span>
{unread ? (
<span className="relative z-10 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--wb-accent)]" />
) : null}
{hasMeta ? (
<span
className={cn(
"relative z-10 shrink-0 text-xs text-muted-foreground/70",
hasActions && "group-hover:hidden",
)}
>
{meta}
</span>
) : null}
</button>
{hasActions ? (
<span className="absolute inset-y-0 right-2 z-10 hidden items-center gap-0.5 group-hover:flex">
{actions}
</span>
) : null}
</div>
);
}
export interface ThreadListActionProps {
"aria-label": string;
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** Small 20px icon button for a `ThreadListItem`'s hover-revealed actions
* (pin, delete, ...) or a `ThreadListSection`'s trailing action slot. */
export function ThreadListAction({
"aria-label": ariaLabel,
onClick,
children,
className,
}: ThreadListActionProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-[var(--wb-hover-stronger)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/blocks/thread-list
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useId } from "react";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ThreadListProps {
className?: string;
children?: ReactNode;
}
/**
* Sidebar conversation/task list root — a tight `flex flex-col` stack of
* `ThreadListSection`s. Wraps `children` in a `LayoutGroup` scoped to this
* instance (via `useId`), so the active-row indicator's `layoutId="active"`
* animation (see `ThreadListItem`) stays local to this list — two
* `ThreadList`s rendered on the same page never fight over which one owns
* the moving highlight.
*/
export function ThreadList({ className, children }: ThreadListProps) {
const groupId = useId();
return (
<LayoutGroup id={groupId}>
<div className={cn("flex flex-col gap-0.5", className)}>{children}</div>
</LayoutGroup>
);
}
export interface ThreadListSectionProps {
/** Muted section label, e.g. "Pinned" or "Recent". */
title?: ReactNode;
/** Trailing slot beside the title, e.g. a "new" `ThreadListAction`. */
action?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Named group of rows inside a `ThreadList`. The header row (label plus an
* optional trailing action) only renders when `title` or `action` is
* passed, so a section can also be used as a bare, unlabeled row group.
*/
export function ThreadListSection({ title, action, className, children }: ThreadListSectionProps) {
return (
<div className={cn("flex flex-col", className)}>
{title !== undefined || action !== undefined ? (
<div className="flex items-center justify-between px-2 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground">{title}</span>
{action}
</div>
) : null}
{children}
</div>
);
}
export interface ThreadListItemProps {
/** Highlights the row and pins the moving indicator here. */
active?: boolean;
/** Renders a small blue dot after the title. */
unread?: boolean;
/** 16px leading icon, e.g. `<MessageSquare className="h-4 w-4" />`. */
icon?: ReactNode;
/** Trailing meta, e.g. a relative timestamp. Hidden on row hover in favor of `actions` when both are passed; stays visible when `actions` is omitted. */
meta?: ReactNode;
/** Row of `ThreadListAction`s revealed on hover, replacing `meta`. */
actions?: ReactNode;
onSelect?: () => void;
className?: string;
/** The row's title — truncates to a single line. */
children?: ReactNode;
}
/**
* One row in a `ThreadList` — a conversation or task entry. The signature
* motion is the active indicator: a `layoutId="active"` pill that glides
* between rows as selection moves (`SPRING_PANEL`), scoped to the enclosing
* `ThreadList`'s `LayoutGroup` so unrelated lists sharing a page don't
* collide. Reduced motion swaps the animated pill for a static span.
*
* Deliberately *not* a single `<button>` wrapping everything: `actions`
* renders real `ThreadListAction` buttons (pin, delete, ...), and a
* `<button>` cannot legally contain another `<button>`. Instead the row is a
* plain container with the selectable button filling it, and — only when
* `actions` is passed — a sibling, absolutely-positioned action cluster
* layered on top at the trailing edge, shown on hover in place of `meta`.
* Both stay real, independently focusable buttons with no nested-control
* hit-testing tricks required.
*/
export function ThreadListItem({
active = false,
unread = false,
icon,
meta,
actions,
onSelect,
className,
children,
}: ThreadListItemProps) {
const reduce = useReducedMotion() ?? false;
const hasMeta = meta !== undefined && meta !== null;
const hasActions = actions !== undefined && actions !== null;
return (
<div className={cn("group relative flex h-[30px] w-full", className)}>
<button
type="button"
onClick={onSelect}
aria-current={active || undefined}
className={cn(
"relative flex w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-sm",
!active && "hover:bg-[var(--wb-hover-subtle)]",
)}
>
{active ? (
reduce ? (
<span aria-hidden className="absolute inset-0 rounded-lg bg-[var(--wb-inset-strong)]" />
) : (
<motion.span
aria-hidden
layoutId="active"
className="absolute inset-0 rounded-lg bg-[var(--wb-inset-strong)]"
transition={SPRING_PANEL}
/>
)
) : null}
{icon ? (
<span className="relative z-10 flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span
className={cn(
"relative z-10 min-w-0 flex-1 truncate",
active ? "text-foreground" : "text-muted-foreground group-hover:text-foreground",
)}
>
{children}
</span>
{unread ? (
<span className="relative z-10 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--wb-accent)]" />
) : null}
{hasMeta ? (
<span
className={cn(
"relative z-10 shrink-0 text-xs text-muted-foreground/70",
hasActions && "group-hover:hidden",
)}
>
{meta}
</span>
) : null}
</button>
{hasActions ? (
<span className="absolute inset-y-0 right-2 z-10 hidden items-center gap-0.5 group-hover:flex">
{actions}
</span>
) : null}
</div>
);
}
export interface ThreadListActionProps {
"aria-label": string;
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** Small 20px icon button for a `ThreadListItem`'s hover-revealed actions
* (pin, delete, ...) or a `ThreadListSection`'s trailing action slot. */
export function ThreadListAction({
"aria-label": ariaLabel,
onClick,
children,
className,
}: ThreadListActionProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-[var(--wb-hover-stronger)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
API 参考
ThreadList
className?string—ThreadListSection
title?ReactNodeMuted section label, e.g. "Pinned" or "Recent".
—action?ReactNodeTrailing slot beside the title, e.g. a "new" `ThreadListAction`.
—className?string—ThreadListItem
active?booleanHighlights the row and pins the moving indicator here.
falseunread?booleanRenders a small blue dot after the title.
falseicon?ReactNode16px leading icon, e.g. `<MessageSquare className="h-4 w-4" />`.
—meta?ReactNodeTrailing meta, e.g. a relative timestamp. Hidden on row hover in favor of `actions` when both are passed; stays visible when `actions` is omitted.
—actions?ReactNodeRow of `ThreadListAction`s revealed on hover, replacing `meta`.
—onSelect?(() => void)—className?string—children?ReactNodeThe row's title — truncates to a single line.
—ThreadListAction
aria-labelstring—onClick?(() => void)—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.