Skip to content

Member List

A server member sidebar: role sections, presence, coloured names and APP tags.

A server member sidebar: role sections, presence, coloured names and APP tags. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-member
import { Avatar } from "@/registry/bases/newt/ui/avatar"
import {
  MemberList,
  MemberListAppTag,
  MemberListHeading,
  MemberListInfo,
  MemberListItem,
  MemberListName,
  MemberListNameRow,
  MemberListRole,
  MemberListSection,
} from "@/registry/bases/newt/ui/member-list"
import { RoleTag } from "@/registry/bases/newt/ui/role-tag"
import {
  StatusDot,
  StatusIndicator,
} from "@/registry/bases/newt/ui/status-indicator"

export default function MemberListDemo() {
  return (
    <MemberList className="w-60">
      <MemberListSection>
        <MemberListHeading>Moderators — 1</MemberListHeading>
        <MemberListItem>
          <StatusIndicator>
            <Avatar size="sm">A</Avatar>
            <StatusDot status="online" />
          </StatusIndicator>
          <MemberListInfo>
            <MemberListNameRow>
              <MemberListName color="#f0b232">username</MemberListName>
              <MemberListAppTag verified />
            </MemberListNameRow>
            <MemberListRole>Role</MemberListRole>
          </MemberListInfo>
          <RoleTag className="ml-auto">TAG</RoleTag>
        </MemberListItem>
      </MemberListSection>
      <MemberListSection offline>
        <MemberListHeading>Offline — 1</MemberListHeading>
        <MemberListItem>
          <StatusIndicator>
            <Avatar size="sm">B</Avatar>
            <StatusDot status="offline" />
          </StatusIndicator>
          <MemberListInfo>
            <MemberListNameRow>
              <MemberListName>someone</MemberListName>
            </MemberListNameRow>
          </MemberListInfo>
        </MemberListItem>
      </MemberListSection>
    </MemberList>
  )
}
<script setup lang="ts">
import { Avatar } from "~~/registry/bases/newt/ui/avatar"
import {
  MemberList,
  MemberListAppTag,
  MemberListHeading,
  MemberListInfo,
  MemberListItem,
  MemberListName,
  MemberListNameRow,
  MemberListRole,
  MemberListSection,
} from "~~/registry/bases/newt/ui/member-list"
import { RoleTag } from "~~/registry/bases/newt/ui/role-tag"
import {
  StatusDot,
  StatusIndicator,
} from "~~/registry/bases/newt/ui/status-indicator"
</script>

<template>
  <MemberList class="w-60">
    <MemberListSection>
      <MemberListHeading>Moderators — 1</MemberListHeading>
      <MemberListItem>
        <StatusIndicator>
          <Avatar size="sm">A</Avatar>
          <StatusDot status="online" />
        </StatusIndicator>
        <MemberListInfo>
          <MemberListNameRow>
            <MemberListName color="#f0b232">username</MemberListName>
            <MemberListAppTag verified />
          </MemberListNameRow>
          <MemberListRole>Role</MemberListRole>
        </MemberListInfo>
        <RoleTag class="ml-auto">TAG</RoleTag>
      </MemberListItem>
    </MemberListSection>
    <MemberListSection offline>
      <MemberListHeading>Offline — 1</MemberListHeading>
      <MemberListItem>
        <StatusIndicator>
          <Avatar size="sm">B</Avatar>
          <StatusDot status="offline" />
        </StatusIndicator>
        <MemberListInfo>
          <MemberListNameRow>
            <MemberListName>someone</MemberListName>
          </MemberListNameRow>
        </MemberListInfo>
      </MemberListItem>
    </MemberListSection>
  </MemberList>
</template>

Installation

pnpm dlx newtui@latest add member-list
npx newtui@latest add member-list
yarn dlx newtui@latest add member-list
bunx newtui@latest add member-list

Add the components it builds on

Copy the source into your project

import * as React from "react"

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

const MemberList = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    role="list"
    className={cn("flex flex-col", className)}
    {...props}
  />
))
MemberList.displayName = "MemberList"

/*
 * Discord groups members under each hoisted role, then Online, then Offline.
 * `offline` dims the whole group rather than every row on its own.
 */
export interface MemberListSectionProps extends React.HTMLAttributes<HTMLElement> {
  offline?: boolean
}

const MemberListSection = React.forwardRef<HTMLElement, MemberListSectionProps>(
  ({ className, offline = false, ...props }, ref) => (
    <section
      ref={ref}
      data-offline={offline || undefined}
      className={cn(
        "[&:not(:first-child)]:mt-3",
        offline &&
          "[&_[role=listitem]]:opacity-45 [&_[data-slot=member-name]]:text-newt-text-muted",
        className
      )}
      {...props}
    />
  )
)
MemberListSection.displayName = "MemberListSection"

const MemberListHeading = React.forwardRef<
  HTMLHeadingElement,
  React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
  <h3
    ref={ref}
    className={cn(
      "m-0 px-2 pb-0.5 text-[12px] font-semibold uppercase leading-4 tracking-[0.02em] text-newt-text-muted",
      className
    )}
    {...props}
  />
))
MemberListHeading.displayName = "MemberListHeading"

const MemberListItem = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    role="listitem"
    className={cn(
      "flex min-w-0 cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 transition-colors duration-fast ease-newt hover:bg-newt-bg-hover",
      className
    )}
    {...props}
  />
))
MemberListItem.displayName = "MemberListItem"

const MemberListNameRow = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex min-w-0 items-center gap-1", className)}
    {...props}
  />
))
MemberListNameRow.displayName = "MemberListNameRow"

const MemberListInfo = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex min-w-0 flex-col leading-[1.2]", className)}
    {...props}
  />
))
MemberListInfo.displayName = "MemberListInfo"

export interface MemberListNameProps extends Omit<
  React.HTMLAttributes<HTMLSpanElement>,
  "color"
> {
  /** Render the name in the brand colour (e.g. highlighted role). */
  colored?: boolean
  /** Role colour. Any CSS colour; wins over `colored`. */
  color?: string
}

const MemberListName = React.forwardRef<HTMLSpanElement, MemberListNameProps>(
  ({ className, colored = false, color, style, ...props }, ref) => (
    <span
      ref={ref}
      data-slot="member-name"
      /*
       * SAFETY: `CSSProperties` has no index signature for custom properties,
       * but React forwards unknown `--*` keys to the style attribute verbatim,
       * so the extra key is written out exactly as spelled here.
       */
      style={
        color
          ? ({
              ...style,
              "--newt-member-name-color": color,
            } as React.CSSProperties)
          : style
      }
      className={cn(
        "truncate text-sm font-medium",
        color
          ? "text-[var(--newt-member-name-color)]"
          : colored
            ? "text-newt-brand"
            : "text-newt-text-primary",
        className
      )}
      {...props}
    />
  )
)
MemberListName.displayName = "MemberListName"

/* The APP tag bots carry next to their name; `verified` adds Discord's check. */
export interface MemberListAppTagProps extends React.HTMLAttributes<HTMLSpanElement> {
  verified?: boolean
}

const MemberListAppTag = React.forwardRef<
  HTMLSpanElement,
  MemberListAppTagProps
>(({ className, verified = false, children = "APP", ...props }, ref) => (
  <span
    ref={ref}
    role="img"
    aria-label={verified ? "Verified application" : "Application"}
    className={cn(
      "inline-flex shrink-0 items-center gap-0.5 rounded-sm bg-newt-brand px-1 py-px text-[9px] font-bold leading-none text-white",
      className
    )}
    {...props}
  >
    {verified ? (
      <svg
        viewBox="0 0 24 24"
        aria-hidden="true"
        focusable="false"
        className="h-2.5 w-2.5"
      >
        <path
          fill="currentColor"
          d="M9.55 17.6 4.4 12.45l1.4-1.4 3.75 3.75 8.65-8.65 1.4 1.4z"
        />
      </svg>
    ) : null}
    {children}
  </span>
))
MemberListAppTag.displayName = "MemberListAppTag"

const MemberListRole = React.forwardRef<
  HTMLSpanElement,
  React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => (
  <span
    ref={ref}
    className={cn("truncate text-xs text-newt-text-muted", className)}
    {...props}
  />
))
MemberListRole.displayName = "MemberListRole"

export {
  MemberList,
  MemberListSection,
  MemberListHeading,
  MemberListItem,
  MemberListInfo,
  MemberListNameRow,
  MemberListName,
  MemberListAppTag,
  MemberListRole,
}
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

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

<template>
  <div role="list" :class="cn('flex flex-col', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

/*
 * Discord groups members under each hoisted role, then Online, then Offline.
 * `offline` dims the whole group rather than every row on its own.
 */
const props = defineProps<{
  class?: HTMLAttributes["class"]
  offline?: boolean
}>()
</script>

<template>
  <section
    :data-offline="props.offline || undefined"
    :class="
      cn(
        '[&:not(:first-child)]:mt-3',
        props.offline &&
          '[&_[role=listitem]]:opacity-45 [&_[data-slot=member-name]]:text-newt-text-muted',
        props.class
      )
    "
  >
    <slot />
  </section>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

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

<template>
  <h3
    :class="
      cn(
        'm-0 px-2 pb-0.5 text-[12px] font-semibold uppercase leading-4 tracking-[0.02em] text-newt-text-muted',
        props.class
      )
    "
  >
    <slot />
  </h3>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

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

<template>
  <div
    role="listitem"
    :class="
      cn(
        'flex min-w-0 cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 transition-colors duration-fast ease-newt hover:bg-newt-bg-hover',
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

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

<template>
  <div :class="cn('flex min-w-0 flex-col leading-[1.2]', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

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

<template>
  <div :class="cn('flex min-w-0 items-center gap-1', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { CSSProperties, HTMLAttributes } from "vue"
import { computed } from "vue"

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

const props = defineProps<{
  class?: HTMLAttributes["class"]
  /** Render the name in the brand colour. */
  colored?: boolean
  /** Role colour. Any CSS colour; wins over `colored`. */
  color?: string
}>()

/*
 * SAFETY: `CSSProperties` has no index signature for custom properties, but
 * Vue writes unknown `--*` keys to the style attribute verbatim, so the extra
 * key lands exactly as spelled here.
 */
const style = computed<CSSProperties | undefined>(() =>
  props.color
    ? ({ "--newt-member-name-color": props.color } as CSSProperties)
    : undefined
)

const colorClass = computed(() => {
  if (props.color) return "text-[var(--newt-member-name-color)]"
  return props.colored ? "text-newt-brand" : "text-newt-text-primary"
})
</script>

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

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

/* The APP tag bots carry next to their name; `verified` adds Discord's check. */
const props = defineProps<{
  class?: HTMLAttributes["class"]
  verified?: boolean
}>()
</script>

<template>
  <span
    role="img"
    :aria-label="props.verified ? 'Verified application' : 'Application'"
    :class="
      cn(
        'inline-flex shrink-0 items-center gap-0.5 rounded-sm bg-newt-brand px-1 py-px text-[9px] font-bold leading-none text-white',
        props.class
      )
    "
  >
    <svg
      v-if="props.verified"
      viewBox="0 0 24 24"
      aria-hidden="true"
      focusable="false"
      class="h-2.5 w-2.5"
    >
      <path
        fill="currentColor"
        d="M9.55 17.6 4.4 12.45l1.4-1.4 3.75 3.75 8.65-8.65 1.4 1.4z"
      />
    </svg>
    <slot>APP</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 text-xs text-newt-text-muted', props.class)">
    <slot />
  </span>
</template>
export { default as MemberList } from "./MemberList.vue"
export { default as MemberListSection } from "./MemberListSection.vue"
export { default as MemberListHeading } from "./MemberListHeading.vue"
export { default as MemberListItem } from "./MemberListItem.vue"
export { default as MemberListInfo } from "./MemberListInfo.vue"
export { default as MemberListNameRow } from "./MemberListNameRow.vue"
export { default as MemberListName } from "./MemberListName.vue"
export { default as MemberListAppTag } from "./MemberListAppTag.vue"
export { default as MemberListRole } from "./MemberListRole.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 { MemberList } from "@/components/ui/member-list"

export function Example() {
  return <MemberList />
}
<script setup lang="ts">
import { MemberList, MemberListSection, MemberListHeading, MemberListItem, MemberListInfo, MemberListNameRow, MemberListName, MemberListAppTag, MemberListRole } from "@/components/ui/member-list"
</script>

<template>
  <MemberList />
</template>

API reference

MemberListSection

PropType
offline?boolean | undefined

Optional offline value.

Typeboolean | undefined

MemberListName

PropType
colored?boolean | undefined

Render the name in the brand colour (e.g. highlighted role).

Typeboolean | undefined
color?string | undefined

Role colour. Any CSS colour; wins over `colored`.

Typestring | undefined

MemberListAppTag

PropType
verified?boolean | undefined

Optional verified value.

Typeboolean | undefined

The registry exports MemberList, MemberListSection, MemberListHeading, MemberListItem, MemberListInfo, MemberListNameRow, MemberListName, MemberListAppTag, MemberListRole. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

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

Member List 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. In the plain HTML and CSS distribution the same component is newt-member.