Skip to content

Server sidebar

The column beside a channel: the server banner, the voice channel and the grouped member list.

The column beside a channel: the server banner, the voice channel and the grouped member list. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

import { ServerSidebar } from "@/registry/bases/newt/blocks/server-sidebar/server-sidebar"

/* Placeholder names: no real account, and no Discord asset. */
const MEMBERS = [
  {
    id: "1",
    name: "alex",
    initials: "A",
    status: "online",
    group: "Moderators",
  },
  { id: "2", name: "sam", initials: "S", status: "idle", group: "Online" },
  { id: "3", name: "robin", initials: "R", status: "dnd", group: "Online" },
  { id: "4", name: "kit", initials: "K", status: "offline", group: "Offline" },
] as const

export default function ServerSidebarDemo() {
  return (
    <ServerSidebar
      className="w-64"
      serverName="Build Club"
      voiceChannel={{ name: "General voice", members: ["alex", "sam"] }}
      members={MEMBERS}
    />
  )
}
<script setup lang="ts">
import { ServerSidebar } from "~~/registry/bases/newt/blocks/server-sidebar"
import type { SidebarMember } from "~~/registry/bases/newt/blocks/server-sidebar"

/* Placeholder names: no real account, and no Discord asset. */
const MEMBERS: SidebarMember[] = [
  {
    id: "1",
    name: "alex",
    initials: "A",
    status: "online",
    group: "Moderators",
  },
  { id: "2", name: "sam", initials: "S", status: "idle", group: "Online" },
  { id: "3", name: "robin", initials: "R", status: "dnd", group: "Online" },
  { id: "4", name: "kit", initials: "K", status: "offline", group: "Offline" },
]
</script>

<template>
  <ServerSidebar
    class="w-64"
    server-name="Build Club"
    :voice-channel="{ name: 'General voice', members: ['alex', 'sam'] }"
    :members="MEMBERS"
  />
</template>

Installation

pnpm dlx newtui@latest add server-sidebar
npx newtui@latest add server-sidebar
yarn dlx newtui@latest add server-sidebar
bunx newtui@latest add server-sidebar

Copy the source into your project

import * as React from "react"

import { Avatar } from "@/registry/bases/newt/ui/avatar"
import {
  MemberList,
  MemberListHeading,
  MemberListInfo,
  MemberListItem,
  MemberListName,
  MemberListNameRow,
  MemberListSection,
} from "@/registry/bases/newt/ui/member-list"
import {
  ServerBanner,
  ServerBannerIcon,
  ServerBannerInfo,
  ServerBannerName,
} from "@/registry/bases/newt/ui/server-banner"
import {
  StatusDot,
  StatusIndicator,
} from "@/registry/bases/newt/ui/status-indicator"
import type { StatusDotProps } from "@/registry/bases/newt/ui/status-indicator"
import {
  VoiceChannel,
  VoiceChannelHeader,
  VoiceChannelIcon,
  VoiceChannelMember,
  VoiceChannelMembers,
} from "@/registry/bases/newt/ui/voice-channel"

/*
 * The column beside a channel: who the server is, who is in voice, and who is
 * around. It is an arrangement of registry components, so a project that wants
 * a different order rearranges it rather than restyling anything.
 */
export interface SidebarMember {
  id: string
  name: string
  initials: string
  status: NonNullable<StatusDotProps["status"]>
  /** The section heading this member belongs under. */
  group: string
}

export interface ServerSidebarProps extends React.ComponentProps<"aside"> {
  serverName: string
  /** Who is currently in the voice channel, in join order. */
  voiceChannel?: { name: string; members: readonly string[] }
  members: readonly SidebarMember[]
}

export function ServerSidebar({
  serverName,
  voiceChannel,
  members,
  className,
  ...props
}: ServerSidebarProps) {
  // Grouped here rather than by the caller: the grouping is a property of how
  // this column reads, not of the data.
  const groups = React.useMemo(() => {
    const byGroup = new Map<string, SidebarMember[]>()
    for (const member of members) {
      const existing = byGroup.get(member.group) ?? []
      existing.push(member)
      byGroup.set(member.group, existing)
    }
    return [...byGroup]
  }, [members])

  return (
    <aside
      className={className}
      aria-label={`${serverName} sidebar`}
      {...props}
    >
      <ServerBanner>
        <ServerBannerIcon>{serverName.slice(0, 1)}</ServerBannerIcon>
        <ServerBannerInfo>
          <ServerBannerName>{serverName}</ServerBannerName>
        </ServerBannerInfo>
      </ServerBanner>

      {voiceChannel && (
        <VoiceChannel>
          <VoiceChannelHeader>
            <VoiceChannelIcon />
            {voiceChannel.name}
          </VoiceChannelHeader>
          <VoiceChannelMembers>
            {voiceChannel.members.map((name) => (
              <VoiceChannelMember key={name}>{name}</VoiceChannelMember>
            ))}
          </VoiceChannelMembers>
        </VoiceChannel>
      )}

      <MemberList>
        {groups.map(([group, groupMembers]) => (
          <MemberListSection key={group}>
            <MemberListHeading>
              {group} — {groupMembers.length}
            </MemberListHeading>
            {groupMembers.map((member) => (
              <MemberListItem key={member.id}>
                <StatusIndicator>
                  <Avatar size="sm">{member.initials}</Avatar>
                  <StatusDot status={member.status} />
                </StatusIndicator>
                <MemberListInfo>
                  <MemberListNameRow>
                    <MemberListName>{member.name}</MemberListName>
                  </MemberListNameRow>
                </MemberListInfo>
              </MemberListItem>
            ))}
          </MemberListSection>
        ))}
      </MemberList>
    </aside>
  )
}
<script setup lang="ts">
import { computed } from "vue"
import { Avatar } from "~~/registry/bases/newt/ui/avatar"
import {
  MemberList,
  MemberListHeading,
  MemberListInfo,
  MemberListItem,
  MemberListName,
  MemberListNameRow,
  MemberListSection,
} from "~~/registry/bases/newt/ui/member-list"
import {
  ServerBanner,
  ServerBannerIcon,
  ServerBannerInfo,
  ServerBannerName,
} from "~~/registry/bases/newt/ui/server-banner"
import {
  StatusDot,
  StatusIndicator,
} from "~~/registry/bases/newt/ui/status-indicator"
import {
  VoiceChannel,
  VoiceChannelHeader,
  VoiceChannelIcon,
  VoiceChannelMember,
  VoiceChannelMembers,
} from "~~/registry/bases/newt/ui/voice-channel"

import type { SidebarMember, VoiceChannelSummary } from "./types"

/*
 * The column beside a channel: who the server is, who is in voice, and who is
 * around. It is an arrangement of registry components, so a project that wants
 * a different order rearranges it rather than restyling anything.
 */
const props = defineProps<{
  serverName: string
  voiceChannel?: VoiceChannelSummary
  members: readonly SidebarMember[]
}>()

// Grouped here rather than by the caller: the grouping is a property of how
// this column reads, not of the data.
const groups = computed(() => {
  const byGroup = new Map<string, SidebarMember[]>()
  for (const member of props.members) {
    const existing = byGroup.get(member.group) ?? []
    existing.push(member)
    byGroup.set(member.group, existing)
  }
  return [...byGroup]
})
</script>

<template>
  <aside :aria-label="`${props.serverName} sidebar`">
    <ServerBanner>
      <ServerBannerIcon>{{ props.serverName.slice(0, 1) }}</ServerBannerIcon>
      <ServerBannerInfo>
        <ServerBannerName>{{ props.serverName }}</ServerBannerName>
      </ServerBannerInfo>
    </ServerBanner>

    <VoiceChannel v-if="props.voiceChannel">
      <VoiceChannelHeader>
        <VoiceChannelIcon />
        {{ props.voiceChannel.name }}
      </VoiceChannelHeader>
      <VoiceChannelMembers>
        <VoiceChannelMember
          v-for="name in props.voiceChannel.members"
          :key="name"
        >
          {{ name }}
        </VoiceChannelMember>
      </VoiceChannelMembers>
    </VoiceChannel>

    <MemberList>
      <MemberListSection v-for="[group, groupMembers] in groups" :key="group">
        <MemberListHeading
          >{{ group }} — {{ groupMembers.length }}</MemberListHeading
        >
        <MemberListItem v-for="member in groupMembers" :key="member.id">
          <StatusIndicator>
            <Avatar size="sm">{{ member.initials }}</Avatar>
            <StatusDot :status="member.status" />
          </StatusIndicator>
          <MemberListInfo>
            <MemberListNameRow>
              <MemberListName>{{ member.name }}</MemberListName>
            </MemberListNameRow>
          </MemberListInfo>
        </MemberListItem>
      </MemberListSection>
    </MemberList>
  </aside>
</template>
export type MemberStatus = "online" | "idle" | "dnd" | "offline" | "streaming"

/*
 * The shape the sidebar reads. A block does not know your data layer; map onto
 * this at the edge of it.
 */
export interface SidebarMember {
  id: string
  name: string
  initials: string
  status: MemberStatus
  /** The section heading this member belongs under. */
  group: string
}

export interface VoiceChannelSummary {
  name: string
  members: readonly string[]
}
export { default as ServerSidebar } from "./ServerSidebar.vue"
export type { MemberStatus, SidebarMember, VoiceChannelSummary } from "./types"

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 { ServerSidebar } from "@/components/server-sidebar"

export function Example() {
  return <ServerSidebar />
}
<script setup lang="ts">
import { ServerSidebar } from "@/components/server-sidebar"
</script>

<template>
  <ServerSidebar />
</template>

API reference

ServerSidebar

PropType
serverName?string

Required serverName value.

Typestring
voiceChannel?{ name: string; members: readonly string[] } | undefined

Who is currently in the voice channel, in join order.

Type{ name: string; members: readonly string[] } | undefined
members?readonly SidebarMember[]

Required members value.

Typereadonly SidebarMember[]

The registry exports SidebarMember, ServerSidebar. 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

Server sidebar 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.