Skip to content

Thread Preview

The thread hanging off a message: its name, its reply count and the last thing said.

The thread hanging off a message: its name, its reply count and the last thing said. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

import {
  ThreadPreview,
  ThreadPreviewCount,
  ThreadPreviewLast,
  ThreadPreviewName,
  ThreadPreviewSpine,
} from "@/registry/bases/newt/ui/thread-preview"

export default function ThreadPreviewDemo() {
  return (
    <ThreadPreview href="#thread">
      <ThreadPreviewSpine />
      <ThreadPreviewName>release-notes</ThreadPreviewName>
      <ThreadPreviewCount count={14} />
      <ThreadPreviewLast>someone: shipping it tonight</ThreadPreviewLast>
    </ThreadPreview>
  )
}
<script setup lang="ts">
import {
  ThreadPreview,
  ThreadPreviewCount,
  ThreadPreviewLast,
  ThreadPreviewName,
  ThreadPreviewSpine,
} from "~~/registry/bases/newt/ui/thread-preview"
</script>

<template>
  <ThreadPreview href="#thread">
    <ThreadPreviewSpine />
    <ThreadPreviewName>release-notes</ThreadPreviewName>
    <ThreadPreviewCount :count="14" />
    <ThreadPreviewLast>someone: shipping it tonight</ThreadPreviewLast>
  </ThreadPreview>
</template>

Installation

pnpm dlx newtui@latest add thread-preview
npx newtui@latest add thread-preview
yarn dlx newtui@latest add thread-preview
bunx newtui@latest add thread-preview

Copy the source into your project

import * as React from "react"

import { cn } from "@/lib/utils"

/*
 * The thread hanging off a message: its name, how many replies it holds, and
 * who spoke last. It is a link to somewhere else, so the whole card is one
 * control rather than a card with a link inside it — one tab stop, one target,
 * and the hover state belongs to all of it.
 */
const ThreadPreview = React.forwardRef<
  HTMLAnchorElement,
  React.AnchorHTMLAttributes<HTMLAnchorElement>
>(({ className, ...props }, ref) => (
  <a
    ref={ref}
    className={cn(
      "flex max-w-[440px] items-center gap-2 rounded-md bg-newt-bg-surface px-3 py-2 text-sm no-underline",
      "transition-colors duration-fast ease-newt hover:bg-newt-bg-elevated",
      "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link",
      className
    )}
    {...props}
  />
))
ThreadPreview.displayName = "ThreadPreview"

/*
 * The elbow that ties the card back to the message above it. Decorative: the
 * relationship is already in the markup, so it is hidden from a reader who is
 * not looking at it.
 */
const ThreadPreviewSpine = React.forwardRef<
  HTMLSpanElement,
  React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => (
  <span
    ref={ref}
    aria-hidden="true"
    className={cn(
      "block h-3 w-4 shrink-0 rounded-ss-[8px] border-s-2 border-t-2 border-newt-border",
      className
    )}
    {...props}
  />
))
ThreadPreviewSpine.displayName = "ThreadPreviewSpine"

const ThreadPreviewName = React.forwardRef<
  HTMLSpanElement,
  React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => (
  <span
    ref={ref}
    className={cn("truncate font-medium text-newt-text-link", className)}
    {...props}
  />
))
ThreadPreviewName.displayName = "ThreadPreviewName"

export interface ThreadPreviewCountProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** How many messages the thread holds. */
  count: number
}

const ThreadPreviewCount = React.forwardRef<
  HTMLSpanElement,
  ThreadPreviewCountProps
>(({ className, count, ...props }, ref) => (
  <span
    ref={ref}
    className={cn(
      "shrink-0 text-xs font-medium tabular-nums text-newt-text-muted",
      className
    )}
    {...props}
  >
    {/* Spelled out rather than a bare number, which would read as an index. */}
    {count === 1 ? "1 message" : `${count} messages`}
  </span>
))
ThreadPreviewCount.displayName = "ThreadPreviewCount"

/** The last thing said in the thread, which is why anyone opens it. */
const ThreadPreviewLast = React.forwardRef<
  HTMLSpanElement,
  React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => (
  <span
    ref={ref}
    className={cn("min-w-0 flex-1 truncate text-newt-text-muted", className)}
    {...props}
  />
))
ThreadPreviewLast.displayName = "ThreadPreviewLast"

export {
  ThreadPreview,
  ThreadPreviewCount,
  ThreadPreviewLast,
  ThreadPreviewName,
  ThreadPreviewSpine,
}
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

/*
 * The thread hanging off a message: its name, how many replies it holds, and
 * who spoke last. It is a link to somewhere else, so the whole card is one
 * control rather than a card with a link inside it — one tab stop, one target,
 * and the hover state belongs to all of it.
 */
const props = defineProps<{
  class?: HTMLAttributes["class"]
  href?: string
}>()
</script>

<template>
  <a
    :href="props.href"
    :class="
      cn(
        'flex max-w-[440px] items-center gap-2 rounded-md bg-newt-bg-surface px-3 py-2 text-sm no-underline',
        'transition-colors duration-fast ease-newt hover:bg-newt-bg-elevated',
        'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link',
        props.class
      )
    "
  >
    <slot />
  </a>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  /** How many messages the thread holds. */
  count: number
}>()
</script>

<template>
  <!-- Spelled out rather than a bare number, which would read as an index. -->
  <span
    :class="
      cn(
        'shrink-0 text-xs font-medium tabular-nums text-newt-text-muted',
        props.class
      )
    "
  >
    {{ props.count === 1 ? "1 message" : `${props.count} messages` }}
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

/** The last thing said in the thread, which is why anyone opens it. */
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <span
    :class="cn('min-w-0 flex-1 truncate text-newt-text-muted', props.class)"
  >
    <slot />
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <span :class="cn('truncate font-medium text-newt-text-link', props.class)">
    <slot />
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

/*
 * The elbow that ties the card back to the message above it. Decorative: the
 * relationship is already in the markup.
 */
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <span
    aria-hidden="true"
    :class="
      cn(
        'block h-3 w-4 shrink-0 rounded-ss-[8px] border-s-2 border-t-2 border-newt-border',
        props.class
      )
    "
  />
</template>
export { default as ThreadPreview } from "./ThreadPreview.vue"
export { default as ThreadPreviewCount } from "./ThreadPreviewCount.vue"
export { default as ThreadPreviewLast } from "./ThreadPreviewLast.vue"
export { default as ThreadPreviewName } from "./ThreadPreviewName.vue"
export { default as ThreadPreviewSpine } from "./ThreadPreviewSpine.vue"

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { ThreadPreview } from "@/components/ui/thread-preview"

export function Example() {
  return <ThreadPreview />
}
<script setup lang="ts">
import { ThreadPreview, ThreadPreviewCount, ThreadPreviewLast, ThreadPreviewName, ThreadPreviewSpine } from "@/components/ui/thread-preview"
</script>

<template>
  <ThreadPreview />
</template>

API reference

ThreadPreview

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Thread Preview reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.