diff --git a/src/components/modals/RoleModal.vue b/src/components/modals/RoleModal.vue
index eadde8c..f488d87 100644
--- a/src/components/modals/RoleModal.vue
+++ b/src/components/modals/RoleModal.vue
@@ -1,5 +1,5 @@
 <template>
-  <Modal v-if="modals.role && availableRoles.length" @close="close">
+  <Modal v-if="isDisplayed" @close="close">
     <h3>
       Choose a new character for
       {{
@@ -8,20 +8,10 @@
           : "bluffing"
       }}
     </h3>
-    <ul class="tokens" v-if="tab === 'editionRoles' || !otherTravelers.size">
+    <ul class="tokens">
       <li
-        v-for="role in availableRoles"
-        :class="[role.team]"
-        :key="role.id"
-        @click="setRole(role)"
-      >
-        <Token :role="role" />
-      </li>
-    </ul>
-    <ul class="tokens" v-if="tab === 'otherTravelers' && otherTravelers.size">
-      <li
-        v-for="role in otherTravelers.values()"
-        :class="[role.team]"
+        v-for="role in displayedRoles"
+        :class="[role.team, { match: queryMatches(role.name) }]"
         :key="role.id"
         @click="setRole(role)"
       >
@@ -45,6 +35,13 @@
         >Other Travelers</span
       >
     </div>
+    <input
+      ref="searchInput"
+      class="role-search"
+      placeholder="Search"
+      v-model="query"
+      @keyup="keyup"
+    />
   </Modal>
 </template>
 
@@ -73,13 +70,22 @@ export default {
       availableRoles.push({});
       return availableRoles;
     },
+    isDisplayed() {
+      return this.modals.role && this.availableRoles.length;
+    },
+    displayedRoles() {
+      if (this.tab === "editionRoles" || !this.otherTravelers.size)
+        return this.availableRoles;
+      else return [...this.otherTravelers.values()];
+    },
     ...mapState(["modals", "roles", "session"]),
     ...mapState("players", ["players"]),
     ...mapState(["otherTravelers"])
   },
   data() {
     return {
-      tab: "editionRoles"
+      tab: "editionRoles",
+      query: ""
     };
   },
   methods: {
@@ -100,14 +106,39 @@ export default {
           value: role
         });
       }
-      this.tab = "editionRoles";
+      this.reset();
       this.$store.commit("toggleModal", "role");
     },
     close() {
-      this.tab = "editionRoles";
+      this.reset();
       this.toggleModal("role");
     },
+    reset() {
+      this.tab = "editionRoles";
+      this.query = "";
+    },
+    queryMatches(name) {
+      const simplify = str => str.replaceAll(/\W+/g, "").toLowerCase();
+      return simplify(name || "").startsWith(simplify(this.query));
+    },
+    keyup(event) {
+      // Allow Escape for modal dialog dismissal.
+      if (event.key == "Esc" || event.key == "Escape") return;
+
+      event.stopPropagation();
+      if (event.key == "Enter") {
+        const matchingRoles = this.displayedRoles.filter(r =>
+          this.queryMatches(r.name)
+        );
+        if (matchingRoles.length === 1) this.setRole(matchingRoles[0]);
+      }
+    },
     ...mapMutations(["toggleModal"])
+  },
+  watch: {
+    isDisplayed(shown) {
+      if (shown) this.$nextTick(() => this.$refs.searchInput.focus());
+    }
   }
 };
 </script>
@@ -140,9 +171,27 @@ ul.tokens li {
     transform: scale(1.2);
     z-index: 10;
   }
+  &:not(.match) {
+    opacity: 0.4;
+  }
 }
 
 #townsquare.spectator ul.tokens li.traveler {
   display: none;
 }
+
+input.role-search {
+  display: block;
+  width: 100%;
+  background: transparent;
+  border: solid white;
+  border-width: 0 0 1px 0;
+  outline: none;
+  color: white;
+  font-size: 1em;
+
+  &:not(:focus) {
+    border-bottom-color: #777;
+  }
+}
 </style>