diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts
index f2ba64237..80ef5ac1f 100644
--- a/packages/backend/src/core/entities/RoleEntityService.ts
+++ b/packages/backend/src/core/entities/RoleEntityService.ts
@@ -25,14 +25,7 @@ export class RoleEntityService {
 	public async pack(
 		src: Role['id'] | Role,
 		me?: { id: User['id'] } | null | undefined,
-		options?: {
-			detail?: boolean;
-		},
 	) {
-		const opts = Object.assign({
-			detail: true,
-		}, options);
-
 		const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
 
 		const assigns = await this.roleAssignmentsRepository.findBy({
@@ -65,9 +58,6 @@ export class RoleEntityService {
 			canEditMembersByModerator: role.canEditMembersByModerator,
 			policies: policies,
 			usersCount: assigns.length,
-			...(opts.detail ? {
-				users: this.userEntityService.packMany(assigns.map(x => x.userId), me),
-			} : {}),
 		});
 	}
 
@@ -75,11 +65,8 @@ export class RoleEntityService {
 	public packMany(
 		roles: any[],
 		me: { id: User['id'] },
-		options?: {
-			detail?: boolean;
-		},
 	) {
-		return Promise.all(roles.map(x => this.pack(x, me, options)));
+		return Promise.all(roles.map(x => this.pack(x, me)));
 	}
 }
 
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index d27f926ef..d3e2219bd 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
 import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
 import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
 import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
+import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
 import * as ep___announcements from './endpoints/announcements.js';
 import * as ep___antennas_create from './endpoints/antennas/create.js';
 import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@@ -277,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
 import * as ep___ping from './endpoints/ping.js';
 import * as ep___pinnedUsers from './endpoints/pinned-users.js';
 import * as ep___promo_read from './endpoints/promo/read.js';
+import * as ep___roles_list from './endpoints/roles/list.js';
+import * as ep___roles_show from './endpoints/roles/show.js';
+import * as ep___roles_users from './endpoints/roles/users.js';
 import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
 import * as ep___resetDb from './endpoints/reset-db.js';
 import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -383,6 +387,7 @@ const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useCla
 const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default };
 const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default };
 const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
+const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
 const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
 const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
 const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
@@ -594,6 +599,9 @@ const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___
 const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default };
 const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default };
 const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default };
+const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
+const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
+const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
 const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
 const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
 const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
@@ -704,6 +712,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$admin_roles_assign,
 		$admin_roles_unassign,
 		$admin_roles_updateDefaultPolicies,
+		$admin_roles_users,
 		$announcements,
 		$antennas_create,
 		$antennas_delete,
@@ -915,6 +924,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$ping,
 		$pinnedUsers,
 		$promo_read,
+		$roles_list,
+		$roles_show,
+		$roles_users,
 		$requestResetPassword,
 		$resetDb,
 		$resetPassword,
@@ -1019,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$admin_roles_assign,
 		$admin_roles_unassign,
 		$admin_roles_updateDefaultPolicies,
+		$admin_roles_users,
 		$announcements,
 		$antennas_create,
 		$antennas_delete,
@@ -1230,6 +1243,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$ping,
 		$pinnedUsers,
 		$promo_read,
+		$roles_list,
+		$roles_show,
+		$roles_users,
 		$requestResetPassword,
 		$resetDb,
 		$resetPassword,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index f7f2b1f37..4d5ed9fb6 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
 import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
 import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
 import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
+import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
 import * as ep___announcements from './endpoints/announcements.js';
 import * as ep___antennas_create from './endpoints/antennas/create.js';
 import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@@ -277,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
 import * as ep___ping from './endpoints/ping.js';
 import * as ep___pinnedUsers from './endpoints/pinned-users.js';
 import * as ep___promo_read from './endpoints/promo/read.js';
+import * as ep___roles_list from './endpoints/roles/list.js';
+import * as ep___roles_show from './endpoints/roles/show.js';
+import * as ep___roles_users from './endpoints/roles/users.js';
 import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
 import * as ep___resetDb from './endpoints/reset-db.js';
 import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -381,6 +385,7 @@ const eps = [
 	['admin/roles/assign', ep___admin_roles_assign],
 	['admin/roles/unassign', ep___admin_roles_unassign],
 	['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
+	['admin/roles/users', ep___admin_roles_users],
 	['announcements', ep___announcements],
 	['antennas/create', ep___antennas_create],
 	['antennas/delete', ep___antennas_delete],
@@ -592,6 +597,9 @@ const eps = [
 	['ping', ep___ping],
 	['pinned-users', ep___pinnedUsers],
 	['promo/read', ep___promo_read],
+	['roles/list', ep___roles_list],
+	['roles/show', ep___roles_show],
+	['roles/users', ep___roles_users],
 	['request-reset-password', ep___requestResetPassword],
 	['reset-db', ep___resetDb],
 	['reset-password', ep___resetPassword],
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/list.ts b/packages/backend/src/server/api/endpoints/admin/roles/list.ts
index ac56de56b..edaf638ea 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/list.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/list.ts
@@ -32,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 			const roles = await this.rolesRepository.find({
 				order: { lastUsedAt: 'DESC' },
 			});
-			return await this.roleEntityService.packMany(roles, me, { detail: false });
+			return await this.roleEntityService.packMany(roles, me);
 		});
 	}
 }
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/show.ts b/packages/backend/src/server/api/endpoints/admin/roles/show.ts
index c83f96191..01028a086 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/show.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/show.ts
@@ -39,12 +39,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 
 		private roleEntityService: RoleEntityService,
 	) {
-		super(meta, paramDef, async (ps) => {
+		super(meta, paramDef, async (ps, me) => {
 			const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
 			if (role == null) {
 				throw new ApiError(meta.errors.noSuchRole);
 			}
-			return await this.roleEntityService.pack(role);
+			return await this.roleEntityService.pack(role, me);
 		});
 	}
 }
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
new file mode 100644
index 000000000..bb016a842
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
@@ -0,0 +1,71 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueryService } from '@/core/QueryService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+	tags: ['admin', 'role', 'users'],
+
+	requireCredential: false,
+	requireAdmin: true,
+
+	errors: {
+		noSuchRole: {
+			message: 'No such role.',
+			code: 'NO_SUCH_ROLE',
+			id: '224eff5e-2488-4b18-b3e7-f50d94421648',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roleId: { type: 'string', format: 'misskey:id' },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+	},
+	required: ['roleId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.rolesRepository)
+		private rolesRepository: RolesRepository,
+
+		@Inject(DI.roleAssignmentsRepository)
+		private roleAssignmentsRepository: RoleAssignmentsRepository,
+
+		private queryService: QueryService,
+		private userEntityService: UserEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const role = await this.rolesRepository.findOneBy({
+				id: ps.roleId,
+			});
+
+			if (role == null) {
+				throw new ApiError(meta.errors.noSuchRole);
+			}
+
+			const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
+				.andWhere('assign.roleId = :roleId', { roleId: role.id })
+				.innerJoinAndSelect('assign.user', 'user');
+
+			const assigns = await query
+				.take(ps.limit)
+				.getMany();
+
+			return await Promise.all(assigns.map(async assign => ({
+				id: assign.id,
+				user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
+			})));
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 823af6d8b..b25a9deb6 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -89,7 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 				moderationNote: profile.moderationNote,
 				signins,
 				policies: await this.roleService.getUserPolicies(user.id),
-				roles: await this.roleEntityService.packMany(roles, me, { detail: false }),
+				roles: await this.roleEntityService.packMany(roles, me),
 			};
 		});
 	}
diff --git a/packages/backend/src/server/api/endpoints/roles/list.ts b/packages/backend/src/server/api/endpoints/roles/list.ts
new file mode 100644
index 000000000..d61c6b8dc
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/roles/list.ts
@@ -0,0 +1,37 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RolesRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
+
+export const meta = {
+	tags: ['role'],
+
+	requireCredential: true,
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+	},
+	required: [
+	],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.rolesRepository)
+		private rolesRepository: RolesRepository,
+
+		private roleEntityService: RoleEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const roles = await this.rolesRepository.findBy({
+				isPublic: true,
+			});
+			return await this.roleEntityService.packMany(roles, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/roles/show.ts b/packages/backend/src/server/api/endpoints/roles/show.ts
new file mode 100644
index 000000000..cc755dcc7
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/roles/show.ts
@@ -0,0 +1,52 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { RolesRepository } from '@/models/index.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['role', 'users'],
+
+	requireCredential: false,
+
+	errors: {
+		noSuchRole: {
+			message: 'No such role.',
+			code: 'NO_SUCH_ROLE',
+			id: 'de5502bf-009a-4639-86c1-fec349e46dcb',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roleId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roleId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.rolesRepository)
+		private rolesRepository: RolesRepository,
+
+		private roleEntityService: RoleEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const role = await this.rolesRepository.findOneBy({
+				id: ps.roleId,
+				isPublic: true,
+			});
+
+			if (role == null) {
+				throw new ApiError(meta.errors.noSuchRole);
+			}
+
+			return await this.roleEntityService.pack(role, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts
new file mode 100644
index 000000000..6e221b6c6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/roles/users.ts
@@ -0,0 +1,71 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueryService } from '@/core/QueryService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['role', 'users'],
+
+	requireCredential: false,
+
+	errors: {
+		noSuchRole: {
+			message: 'No such role.',
+			code: 'NO_SUCH_ROLE',
+			id: '30aaaee3-4792-48dc-ab0d-cf501a575ac5',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roleId: { type: 'string', format: 'misskey:id' },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+	},
+	required: ['roleId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.rolesRepository)
+		private rolesRepository: RolesRepository,
+
+		@Inject(DI.roleAssignmentsRepository)
+		private roleAssignmentsRepository: RoleAssignmentsRepository,
+
+		private queryService: QueryService,
+		private userEntityService: UserEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const role = await this.rolesRepository.findOneBy({
+				id: ps.roleId,
+				isPublic: true,
+			});
+
+			if (role == null) {
+				throw new ApiError(meta.errors.noSuchRole);
+			}
+
+			const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
+				.andWhere('assign.roleId = :roleId', { roleId: role.id })
+				.innerJoinAndSelect('assign.user', 'user');
+
+			const assigns = await query
+				.take(ps.limit)
+				.getMany();
+
+			return await Promise.all(assigns.map(async assign => ({
+				id: assign.id,
+				user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
+			})));
+		});
+	}
+}
diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue
index 8c1d7af19..2f5866f34 100644
--- a/packages/frontend/src/components/MkRolePreview.vue
+++ b/packages/frontend/src/components/MkRolePreview.vue
@@ -1,10 +1,15 @@
 <template>
-<MkA v-adaptive-bg :to="`/admin/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
+<MkA v-adaptive-bg :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
 	<div :class="$style.title">
 		<span :class="$style.icon">
-			<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i>
-			<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
-			<i v-else class="ti ti-user" style="opacity: 0.7;"></i>
+			<template v-if="role.iconUrl">
+				<img :class="$style.badge" :src="role.iconUrl"/>
+			</template>
+			<template v-else>
+				<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i>
+				<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
+				<i v-else class="ti ti-user" style="opacity: 0.7;"></i>
+			</template>
 		</span>
 		<span :class="$style.name">{{ role.name }}</span>
 		<span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span>
@@ -20,6 +25,7 @@ import { i18n } from '@/i18n';
 
 const props = defineProps<{
 	role: any;
+	forModeration: boolean;
 }>();
 </script>
 
@@ -38,6 +44,11 @@ const props = defineProps<{
 	margin-right: 8px;
 }
 
+.badge {
+	height: 1.3em;
+	vertical-align: -20%;
+}
+
 .name {
 	font-weight: bold;
 }
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
index dd683fcc2..51eb426e9 100644
--- a/packages/frontend/src/components/MkUserList.vue
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -7,9 +7,9 @@
 		</div>
 	</template>
 
-	<template #default="{ items: users }">
+	<template #default="{ items }">
 		<div class="efvhhmdq">
-			<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
+			<MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
 		</div>
 	</template>
 </MkPagination>
@@ -20,10 +20,13 @@ import MkUserInfo from '@/components/MkUserInfo.vue';
 import MkPagination, { Paging } from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n';
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
 	pagination: Paging;
 	noGap?: boolean;
-}>();
+	extractor?: (item: any) => any;
+}>(), {
+	extractor: (item) => item,
+});
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index 0365165b5..1b9f0e7c5 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -16,16 +16,29 @@
 				<MkFolder v-if="role.target === 'manual'" default-open>
 					<template #icon><i class="ti ti-users"></i></template>
 					<template #label>{{ i18n.ts.users }}</template>
-					<template #suffix>{{ role.users.length }}</template>
+					<template #suffix>{{ role.usersCount }}</template>
 					<div class="_gaps">
 						<MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
 
-						<div v-for="user in role.users" :key="user.id" :class="$style.userItem">
-							<MkA :class="$style.user" :to="`/user-info/${user.id}`">
-								<MkUserCardMini :user="user"/>
-							</MkA>
-							<button class="_button" :class="$style.unassign" @click="unassign(user, $event)"><i class="ti ti-x"></i></button>
-						</div>
+						<MkPagination :pagination="usersPagination">
+							<template #empty>
+								<div class="_fullinfo">
+									<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+									<div>{{ i18n.ts.noUsers }}</div>
+								</div>
+							</template>
+
+							<template #default="{ items }">
+								<div class="_gaps_s">
+									<div v-for="item in items" :key="item.user.id" :class="$style.userItem">
+										<MkA :class="$style.user" :to="`/user-info/${item.user.id}`">
+											<MkUserCardMini :user="item.user"/>
+										</MkA>
+										<button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button>
+									</div>
+								</div>
+							</template>
+						</MkPagination>
 					</div>
 				</MkFolder>
 				<MkInfo v-else>{{ i18n.ts._role.isConditionalRole }}</MkInfo>
@@ -47,6 +60,7 @@ import { useRouter } from '@/router';
 import MkButton from '@/components/MkButton.vue';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkInfo from '@/components/MkInfo.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
 
 const router = useRouter();
 
@@ -54,6 +68,14 @@ const props = defineProps<{
 	id?: string;
 }>();
 
+const usersPagination = {
+	endpoint: 'admin/roles/users' as const,
+	limit: 20,
+	params: computed(() => ({
+		roleId: props.id,
+	})),
+};
+
 const role = reactive(await os.api('admin/roles/show', {
 	roleId: props.id,
 }));
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 19a0f6617..d89f0d2a7 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -133,7 +133,7 @@
 					</div>
 				</MkFolder>
 				<div class="_gaps_s">
-					<MkRolePreview v-for="role in roles" :key="role.id" :role="role"/>
+					<MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="true"/>
 				</div>
 			</div>
 		</MkSpacer>
diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue
new file mode 100644
index 000000000..8be11008c
--- /dev/null
+++ b/packages/frontend/src/pages/explore.roles.vue
@@ -0,0 +1,22 @@
+<template>
+<MkSpacer :content-max="1200">
+	<div class="_gaps_s">
+		<MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="false"/>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkRolePreview from '@/components/MkRolePreview.vue';
+import * as os from '@/os';
+
+let roles = $ref();
+
+os.api('roles/list', {
+	limit: 30,
+}).then(res => {
+	roles = res;
+});
+</script>
+
diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue
index 4d6ac7d71..05a36c950 100644
--- a/packages/frontend/src/pages/explore.users.vue
+++ b/packages/frontend/src/pages/explore.users.vue
@@ -8,19 +8,19 @@
 		<template v-if="tag == null">
 			<MkFoldableSection class="_margin" persist-key="explore-pinned-users">
 				<template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
-				<XUserList :pagination="pinnedUsers"/>
+				<MkUserList :pagination="pinnedUsers"/>
 			</MkFoldableSection>
 			<MkFoldableSection class="_margin" persist-key="explore-popular-users">
 				<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
-				<XUserList :pagination="popularUsers"/>
+				<MkUserList :pagination="popularUsers"/>
 			</MkFoldableSection>
 			<MkFoldableSection class="_margin" persist-key="explore-recently-updated-users">
 				<template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
-				<XUserList :pagination="recentlyUpdatedUsers"/>
+				<MkUserList :pagination="recentlyUpdatedUsers"/>
 			</MkFoldableSection>
 			<MkFoldableSection class="_margin" persist-key="explore-recently-registered-users">
 				<template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
-				<XUserList :pagination="recentlyRegisteredUsers"/>
+				<MkUserList :pagination="recentlyRegisteredUsers"/>
 			</MkFoldableSection>
 		</template>
 	</div>
@@ -36,21 +36,21 @@
 
 		<MkFoldableSection v-if="tag != null" :key="`${tag}`" class="_margin">
 			<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
-			<XUserList :pagination="tagUsers"/>
+			<MkUserList :pagination="tagUsers"/>
 		</MkFoldableSection>
 
 		<template v-if="tag == null">
 			<MkFoldableSection class="_margin">
 				<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
-				<XUserList :pagination="popularUsersF"/>
+				<MkUserList :pagination="popularUsersF"/>
 			</MkFoldableSection>
 			<MkFoldableSection class="_margin">
 				<template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
-				<XUserList :pagination="recentlyUpdatedUsersF"/>
+				<MkUserList :pagination="recentlyUpdatedUsersF"/>
 			</MkFoldableSection>
 			<MkFoldableSection class="_margin">
 				<template #header><i class="ti ti-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
-				<XUserList :pagination="recentlyRegisteredUsersF"/>
+				<MkUserList :pagination="recentlyRegisteredUsersF"/>
 			</MkFoldableSection>
 		</template>
 	</div>
@@ -59,7 +59,7 @@
 
 <script lang="ts" setup>
 import { watch } from 'vue';
-import XUserList from '@/components/MkUserList.vue';
+import MkUserList from '@/components/MkUserList.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkTab from '@/components/MkTab.vue';
 import * as os from '@/os';
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
index dd1685f27..0ed0a7ebc 100644
--- a/packages/frontend/src/pages/explore.vue
+++ b/packages/frontend/src/pages/explore.vue
@@ -8,6 +8,9 @@
 		<div v-else-if="tab === 'users'">
 			<XUsers/>
 		</div>
+		<div v-else-if="tab === 'roles'">
+			<XRoles/>
+		</div>
 		<div v-else-if="tab === 'search'">
 			<MkSpacer :content-max="1200">
 				<div>
@@ -22,7 +25,7 @@
 					</MkRadios>
 				</div>
 
-				<XUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/>
+				<MkUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/>
 			</MkSpacer>
 		</div>
 	</div>
@@ -33,12 +36,13 @@
 import { computed, watch } from 'vue';
 import XFeatured from './explore.featured.vue';
 import XUsers from './explore.users.vue';
+import XRoles from './explore.roles.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkRadios from '@/components/MkRadios.vue';
 import { definePageMetadata } from '@/scripts/page-metadata';
 import { i18n } from '@/i18n';
-import XUserList from '@/components/MkUserList.vue';
+import MkUserList from '@/components/MkUserList.vue';
 
 const props = withDefaults(defineProps<{
 	tag?: string;
@@ -75,8 +79,13 @@ const headerTabs = $computed(() => [{
 	key: 'users',
 	icon: 'ti ti-users',
 	title: i18n.ts.users,
+}, {
+	key: 'roles',
+	icon: 'ti ti-badges',
+	title: i18n.ts.roles,
 }, {
 	key: 'search',
+	icon: 'ti ti-search',
 	title: i18n.ts.search,
 }]);
 
diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue
new file mode 100644
index 000000000..2e9d3d616
--- /dev/null
+++ b/packages/frontend/src/pages/role.vue
@@ -0,0 +1,47 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+
+	<MkSpacer :content-max="1200">
+		<div class="_gaps_s">
+			<div v-if="role">{{ role.description }}</div>
+			<MkUserList :pagination="users" :extractor="(item) => item.user"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import * as os from '@/os';
+import MkUserList from '@/components/MkUserList.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	role: string;
+}>();
+
+let role = $ref();
+
+watch(() => props.role, () => {
+	os.api('roles/show', {
+		roleId: props.role,
+	}).then(res => {
+		role = res;
+	});
+}, { immediate: true });
+
+const users = $computed(() => ({
+	endpoint: 'roles/users' as const,
+	limit: 30,
+	params: {
+		roleId: props.role,
+	},
+}));
+
+definePageMetadata(computed(() => ({
+	title: role?.name,
+	icon: 'ti ti-badge',
+})));
+</script>
+
diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue
index b8ed5787c..13a06286f 100644
--- a/packages/frontend/src/pages/user-info.vue
+++ b/packages/frontend/src/pages/user-info.vue
@@ -112,7 +112,7 @@
 						<MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
 
 						<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
-							<MkRolePreview :class="$style.role" :role="role"/>
+							<MkRolePreview :class="$style.role" :role="role" :for-moderation="true"/>
 							<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
 							<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
 						</div>
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index ff380df63..3b7ee1486 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -197,6 +197,9 @@ export const routes = [{
 	path: '/theme-editor',
 	component: page(() => import('./pages/theme-editor.vue')),
 	loginRequired: true,
+}, {
+	path: '/roles/:role',
+	component: page(() => import('./pages/role.vue')),
 }, {
 	path: '/explore/tags/:tag',
 	component: page(() => import('./pages/explore.vue')),