diff --git a/Cargo.lock b/Cargo.lock
index e6eb69a527706ae4d1ff815c3a2fb86e2921f005..e70356486b16ce06a2f46cda493253c2637b18fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -416,6 +416,7 @@ dependencies = [
  "insta",
  "js-sys",
  "sys-locale",
+ "unicode-segmentation",
  "util",
  "wasm-bindgen",
  "wasm-bindgen-futures",
diff --git a/Cargo.toml b/Cargo.toml
index 2acddefec71c18c8ceec6a408a8d442752829a2a..2c425e21aa9fe12f9ddf1d57732b6403f91dc97c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,6 @@ exclude = ["assets/*"]
 
 [features]
 multicam = []
-placeholder = []
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
@@ -33,10 +32,11 @@ bevy = { version = "0.13", default-features = false, features = [
 ] }
 cosmic-text = { version = "0.11.2" }
 # TODO: move crossbeam to wasm32, once input.rs has separate wasm copy/paste fn
+unicode-segmentation = { version = "1.11.0" }
+
 crossbeam-channel = "0.5.8"
 image = "0.24.6"
 sys-locale = "0.3.0"
-
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 arboard = "3.2.0"
 
diff --git a/examples/password.rs b/examples/password.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0539f39557b5f438a56a570ade71930164928b4f
--- /dev/null
+++ b/examples/password.rs
@@ -0,0 +1,47 @@
+use bevy::prelude::*;
+use bevy_cosmic_edit::{password::Password, placeholder::Placeholder, *};
+use util::{change_active_editor_sprite, deselect_editor_on_esc, print_editor_text};
+
+fn setup(mut commands: Commands) {
+    commands.spawn(Camera2dBundle::default());
+
+    // Sprite editor
+    commands.spawn((
+        CosmicEditBundle {
+            max_lines: CosmicMaxLines(1),
+            mode: CosmicMode::InfiniteLine,
+            sprite_bundle: SpriteBundle {
+                // Sets size of text box
+                sprite: Sprite {
+                    custom_size: Some(Vec2::new(300., 100.)),
+                    ..default()
+                },
+                // Position of text box
+                transform: Transform::from_xyz(0., 100., 0.),
+                ..default()
+            },
+            ..default()
+        },
+        Password::default(),
+        Placeholder::new("Password", Attrs::new()),
+    ));
+}
+
+fn main() {
+    App::new()
+        .add_plugins(DefaultPlugins)
+        .add_plugins(CosmicEditPlugin {
+            change_cursor: CursorConfig::Default,
+            ..default()
+        })
+        .add_systems(Startup, setup)
+        .add_systems(
+            Update,
+            (
+                change_active_editor_sprite,
+                deselect_editor_on_esc,
+                print_editor_text.after(KbInput),
+            ),
+        )
+        .run();
+}
diff --git a/src/buffer.rs b/src/buffer.rs
index 1d32295ba20b873ad0403f7aa34f931e0f1056f6..1244e35b633c7ac39d5acee4ab042c7f576e2d05 100644
--- a/src/buffer.rs
+++ b/src/buffer.rs
@@ -1,6 +1,36 @@
 use crate::*;
 use bevy::{prelude::*, window::PrimaryWindow};
 
+pub trait BufferExtras {
+    fn get_text(&self) -> String;
+}
+
+impl BufferExtras for Buffer {
+    /// Retrieves the text content from a buffer.
+    ///
+    /// # Arguments
+    ///
+    /// * none, takes the rust magic ref to self
+    ///
+    /// # Returns
+    ///
+    /// A `String` containing the cosmic text content.
+    fn get_text(&self) -> String {
+        let mut text = String::new();
+        let line_count = self.lines.len();
+
+        for (i, line) in self.lines.iter().enumerate() {
+            text.push_str(line.text());
+
+            if i < line_count - 1 {
+                text.push('\n');
+            }
+        }
+
+        text
+    }
+}
+
 #[derive(Component, Deref, DerefMut)]
 pub struct CosmicBuffer(pub Buffer);
 
@@ -66,29 +96,6 @@ impl<'s, 'r> CosmicBuffer {
         self
     }
 
-    /// Retrieves the cosmic text content from a buffer.
-    ///
-    /// # Arguments
-    ///
-    /// * none, takes the rust magic ref to self
-    ///
-    /// # Returns
-    ///
-    /// A `String` containing the cosmic text content.
-    pub fn get_text(&self) -> String {
-        let mut text = String::new();
-        let line_count = self.lines.len();
-
-        for (i, line) in self.lines.iter().enumerate() {
-            text.push_str(line.text());
-
-            if i < line_count - 1 {
-                text.push('\n');
-            }
-        }
-
-        text
-    }
     /// Returns texts from a MultiStyle buffer
     pub fn get_text_spans(&self, default_attrs: AttrsOwned) -> Vec<Vec<(String, AttrsOwned)>> {
         // TODO: untested!
diff --git a/src/input.rs b/src/input.rs
index 9bb5477d732c52e0822f1c48d120e36e0a5019b1..be89f1fe277b9f8583e8b88bb60838d7a75c2d51 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -20,7 +20,7 @@ use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::JsFuture;
 
 use crate::{
-    buffer::{get_x_offset_center, get_y_offset_center},
+    buffer::{get_x_offset_center, get_y_offset_center, BufferExtras},
     get_node_cursor_pos, CosmicBuffer, CosmicEditor, CosmicFontSystem, CosmicMaxChars,
     CosmicMaxLines, CosmicSource, CosmicTextChanged, CosmicTextPosition, FocusedWidget, ReadOnly,
     XOffset,
@@ -227,40 +227,20 @@ pub(crate) fn input_mouse(
     }
 }
 
-#[derive(Component)]
-pub struct PasswordInput; // PLACEHOLDER bc this fn uses it's presence
-
-// TODO: split copy/paste into own fn, separate fn for wasm
-pub(crate) fn input_kb(
+pub fn kb_move_cursor(
     active_editor: Res<FocusedWidget>,
     keys: Res<ButtonInput<KeyCode>>,
-    mut char_evr: EventReader<ReceivedCharacter>,
-    mut cosmic_edit_query: Query<(
-        &mut CosmicEditor,
-        &mut CosmicBuffer,
-        &CosmicMaxLines,
-        &CosmicMaxChars,
-        Entity,
-        Option<&ReadOnly>,
-        Option<&PasswordInput>,
-    )>,
-    mut evw_changed: EventWriter<CosmicTextChanged>,
+    mut cosmic_edit_query: Query<(&mut CosmicEditor,)>,
     mut font_system: ResMut<CosmicFontSystem>,
-    mut is_deleting: Local<bool>,
-    _channel: Option<Res<WasmPasteAsyncChannel>>,
 ) {
     let Some(active_editor_entity) = active_editor.0 else {
         return;
     };
-
-    if let Ok((mut editor, buffer, max_lines, max_chars, entity, readonly_opt, password_opt)) =
-        cosmic_edit_query.get_mut(active_editor_entity)
-    {
+    if let Ok((mut editor,)) = cosmic_edit_query.get_mut(active_editor_entity) {
         if keys.get_just_pressed().len() != 0 {
             editor.cursor_visible = true;
             editor.cursor_timer.reset();
         }
-        let readonly = readonly_opt.is_some();
 
         let command = keypress_command(&keys);
 
@@ -277,11 +257,11 @@ pub(crate) fn input_kb(
             command
         };
 
-        let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
-
         #[cfg(target_os = "macos")]
         let option = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]);
 
+        let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
+
         // if shift key is pressed
         let already_has_selection = editor.selection() != Selection::None;
         if shift && !already_has_selection {
@@ -351,6 +331,78 @@ pub(crate) fn input_kb(
             }
             return;
         }
+        if keys.just_pressed(KeyCode::Escape) {
+            editor.action(&mut font_system.0, Action::Escape);
+        }
+        if command && keys.just_pressed(KeyCode::KeyA) {
+            editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd));
+            let current_cursor = editor.cursor();
+            editor.set_selection(Selection::Normal(Cursor {
+                line: 0,
+                index: 0,
+                affinity: current_cursor.affinity,
+            }));
+            return;
+        }
+        if keys.just_pressed(KeyCode::Home) {
+            editor.action(&mut font_system.0, Action::Motion(Motion::Home));
+            if !shift {
+                editor.set_selection(Selection::None);
+            }
+            return;
+        }
+        if keys.just_pressed(KeyCode::End) {
+            editor.action(&mut font_system.0, Action::Motion(Motion::End));
+            if !shift {
+                editor.set_selection(Selection::None);
+            }
+            return;
+        }
+        if keys.just_pressed(KeyCode::PageUp) {
+            editor.action(&mut font_system.0, Action::Motion(Motion::PageUp));
+            if !shift {
+                editor.set_selection(Selection::None);
+            }
+            return;
+        }
+        if keys.just_pressed(KeyCode::PageDown) {
+            editor.action(&mut font_system.0, Action::Motion(Motion::PageDown));
+            if !shift {
+                editor.set_selection(Selection::None);
+            }
+        }
+    }
+}
+
+pub(crate) fn kb_input_text(
+    active_editor: Res<FocusedWidget>,
+    keys: Res<ButtonInput<KeyCode>>,
+    mut char_evr: EventReader<ReceivedCharacter>,
+    mut cosmic_edit_query: Query<(
+        &mut CosmicEditor,
+        &mut CosmicBuffer,
+        &CosmicMaxLines,
+        &CosmicMaxChars,
+        Entity,
+        Option<&ReadOnly>,
+    )>,
+    mut evw_changed: EventWriter<CosmicTextChanged>,
+    mut font_system: ResMut<CosmicFontSystem>,
+    mut is_deleting: Local<bool>,
+) {
+    let Some(active_editor_entity) = active_editor.0 else {
+        return;
+    };
+
+    if let Ok((mut editor, buffer, max_lines, max_chars, entity, readonly_opt)) =
+        cosmic_edit_query.get_mut(active_editor_entity)
+    {
+        let command = keypress_command(&keys);
+        if keys.get_just_pressed().len() != 0 {
+            editor.cursor_visible = true;
+            editor.cursor_timer.reset();
+        }
+        let readonly = readonly_opt.is_some();
 
         if keys.just_pressed(KeyCode::Backspace) & !readonly {
             // fix for issue #8
@@ -389,65 +441,84 @@ pub(crate) fn input_kb(
             editor.action(&mut font_system.0, Action::Delete);
             editor.with_buffer_mut(|b| b.set_redraw(true));
         }
-        if keys.just_pressed(KeyCode::Escape) {
-            editor.action(&mut font_system.0, Action::Escape);
-        }
-        if command && keys.just_pressed(KeyCode::KeyA) {
-            editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd));
-            let current_cursor = editor.cursor();
-            editor.set_selection(Selection::Normal(Cursor {
-                line: 0,
-                index: 0,
-                affinity: current_cursor.affinity,
-            }));
-            return;
-        }
-        if keys.just_pressed(KeyCode::Home) {
-            editor.action(&mut font_system.0, Action::Motion(Motion::Home));
-            if !shift {
-                editor.set_selection(Selection::None);
-            }
+
+        if readonly {
             return;
         }
-        if keys.just_pressed(KeyCode::End) {
-            editor.action(&mut font_system.0, Action::Motion(Motion::End));
-            if !shift {
-                editor.set_selection(Selection::None);
+
+        let mut is_edit = false;
+        let mut is_return = false;
+        if keys.just_pressed(KeyCode::Enter) {
+            is_return = true;
+            if (max_lines.0 == 0 || buffer.lines.len() < max_lines.0)
+                && (max_chars.0 == 0 || buffer.get_text().len() < max_chars.0)
+            {
+                // to have new line on wasm rather than E
+                is_edit = true;
+                editor.action(&mut font_system.0, Action::Insert('\n'));
             }
-            return;
         }
-        if keys.just_pressed(KeyCode::PageUp) {
-            editor.action(&mut font_system.0, Action::Motion(Motion::PageUp));
-            if !shift {
-                editor.set_selection(Selection::None);
+
+        if !is_return {
+            for char_ev in char_evr.read() {
+                is_edit = true;
+                if *is_deleting {
+                    editor.action(&mut font_system.0, Action::Backspace);
+                } else if !command && (max_chars.0 == 0 || buffer.get_text().len() < max_chars.0) {
+                    let b = char_ev.char.as_bytes();
+                    for c in b {
+                        let c: char = (*c).into();
+                        editor.action(&mut font_system.0, Action::Insert(c));
+                    }
+                }
             }
-            return;
         }
-        if keys.just_pressed(KeyCode::PageDown) {
-            editor.action(&mut font_system.0, Action::Motion(Motion::PageDown));
-            if !shift {
-                editor.set_selection(Selection::None);
-            }
+
+        if !is_edit {
             return;
         }
 
+        evw_changed.send(CosmicTextChanged((entity, buffer.get_text())));
+    }
+}
+
+pub fn kb_clipboard(
+    active_editor: Res<FocusedWidget>,
+    keys: Res<ButtonInput<KeyCode>>,
+    mut evw_changed: EventWriter<CosmicTextChanged>,
+    mut font_system: ResMut<CosmicFontSystem>,
+    mut cosmic_edit_query: Query<(
+        &mut CosmicEditor,
+        &mut CosmicBuffer,
+        &CosmicMaxLines,
+        &CosmicMaxChars,
+        Entity,
+        Option<&ReadOnly>,
+    )>,
+    _channel: Option<Res<WasmPasteAsyncChannel>>,
+) {
+    let Some(active_editor_entity) = active_editor.0 else {
+        return;
+    };
+
+    if let Ok((mut editor, buffer, max_lines, max_chars, entity, readonly_opt)) =
+        cosmic_edit_query.get_mut(active_editor_entity)
+    {
+        let command = keypress_command(&keys);
+
+        let readonly = readonly_opt.is_some();
+
         let mut is_clipboard = false;
         #[cfg(not(target_arch = "wasm32"))]
         {
             if let Ok(mut clipboard) = arboard::Clipboard::new() {
                 if command && keys.just_pressed(KeyCode::KeyC) {
-                    if password_opt.is_some() {
-                        return;
-                    }
                     if let Some(text) = editor.copy_selection() {
                         clipboard.set_text(text).unwrap();
                         return;
                     }
                 }
                 if command && keys.just_pressed(KeyCode::KeyX) && !readonly {
-                    if password_opt.is_some() {
-                        return;
-                    }
                     if let Some(text) = editor.copy_selection() {
                         clipboard.set_text(text).unwrap();
                         editor.delete_selection();
@@ -463,10 +534,6 @@ pub(crate) fn input_kb(
                                         editor.action(&mut font_system.0, Action::Insert(c));
                                     }
                                 } else {
-                                    if password_opt.is_some() && c.len_utf8() > 1 {
-                                        println!("Cannot input multi-byte char '{}' to password field! See https://github.com/StaffEngineer/bevy_cosmic_edit/pull/99#issuecomment-1782607486",c);
-                                        continue;
-                                    }
                                     editor.action(&mut font_system.0, Action::Insert(c));
                                 }
                             }
@@ -480,9 +547,6 @@ pub(crate) fn input_kb(
         #[cfg(target_arch = "wasm32")]
         {
             if command && keys.just_pressed(KeyCode::KeyC) {
-                if password_opt.is_some() {
-                    return;
-                }
                 if let Some(text) = editor.copy_selection() {
                     write_clipboard_wasm(text.as_str());
                     return;
@@ -490,9 +554,6 @@ pub(crate) fn input_kb(
             }
 
             if command && keys.just_pressed(KeyCode::KeyX) && !readonly {
-                if password_opt.is_some() {
-                    return;
-                }
                 if let Some(text) = editor.copy_selection() {
                     write_clipboard_wasm(text.as_str());
                     editor.delete_selection();
@@ -517,43 +578,7 @@ pub(crate) fn input_kb(
             }
         }
 
-        if readonly {
-            return;
-        }
-
-        let mut is_edit = is_clipboard;
-        let mut is_return = false;
-        if keys.just_pressed(KeyCode::Enter) {
-            is_return = true;
-            if (max_lines.0 == 0 || buffer.lines.len() < max_lines.0)
-                && (max_chars.0 == 0 || buffer.get_text().len() < max_chars.0)
-            {
-                // to have new line on wasm rather than E
-                is_edit = true;
-                editor.action(&mut font_system.0, Action::Insert('\n'));
-            }
-        }
-
-        if !(is_clipboard || is_return) {
-            for char_ev in char_evr.read() {
-                is_edit = true;
-                if *is_deleting {
-                    editor.action(&mut font_system.0, Action::Backspace);
-                } else if !command && (max_chars.0 == 0 || buffer.get_text().len() < max_chars.0) {
-                    if password_opt.is_some() && char_ev.char.len() > 1 {
-                        println!("Cannot input multi-byte char '{}' to password field! See https://github.com/StaffEngineer/bevy_cosmic_edit/pull/99#issuecomment-1782607486",char_ev.char);
-                        continue;
-                    }
-                    let b = char_ev.char.as_bytes();
-                    for c in b {
-                        let c: char = (*c).into();
-                        editor.action(&mut font_system.0, Action::Insert(c));
-                    }
-                }
-            }
-        }
-
-        if !is_edit {
+        if !is_clipboard {
             return;
         }
 
@@ -616,7 +641,6 @@ pub fn poll_wasm_paste(
             &crate::DefaultAttrs,
             &CosmicMaxChars,
             &CosmicMaxChars,
-            Option<&PasswordInput>,
         ),
         Without<ReadOnly>,
     >,
@@ -627,7 +651,7 @@ pub fn poll_wasm_paste(
     match inlet {
         Ok(inlet) => {
             let entity = inlet.entity;
-            if let Ok((mut editor, mut buffer, attrs, max_chars, max_lines, password_opt)) =
+            if let Ok((mut editor, mut buffer, attrs, max_chars, max_lines)) =
                 editor_q.get_mut(entity)
             {
                 let text = inlet.text;
@@ -639,10 +663,6 @@ pub fn poll_wasm_paste(
                                 editor.action(&mut font_system.0, Action::Insert(c));
                             }
                         } else {
-                            if password_opt.is_some() && c.len_utf8() > 1 {
-                                info!("Cannot input multi-byte char '{}' to password field! See https://github.com/StaffEngineer/bevy_cosmic_edit/pull/99#issuecomment-1782607486",c);
-                                continue;
-                            }
                             editor.action(&mut font_system.0, Action::Insert(c));
                         }
                     }
diff --git a/src/lib.rs b/src/lib.rs
index 726f5a242712ffeace3c930240f7ae0691c553c0..1cc13e040cc458929113ac5fb4116b6ef813a7af 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -26,7 +26,7 @@ use cosmic_text::{Buffer, Editor, FontSystem, SwashCache};
 use cursor::{change_cursor, hover_sprites, hover_ui};
 pub use cursor::{TextHoverIn, TextHoverOut};
 pub use focus::*;
-use input::{input_kb, input_mouse, ClickTimer};
+use input::{input_mouse, kb_clipboard, kb_input_text, kb_move_cursor, ClickTimer};
 #[cfg(target_arch = "wasm32")]
 use input::{poll_wasm_paste, WasmPaste, WasmPasteAsyncChannel};
 use layout::{
@@ -203,6 +203,9 @@ impl Default for CosmicFontConfig {
 #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
 pub struct KbInput;
 
+#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
+pub struct Render;
+
 /// Plugin struct that adds systems and initializes resources related to cosmic edit functionality.
 #[derive(Default)]
 pub struct CosmicEditPlugin {
@@ -237,7 +240,12 @@ impl Plugin for CosmicEditPlugin {
         .add_systems(PreUpdate, (input_mouse,).chain())
         .add_systems(
             Update,
-            (input_kb, reshape, blink_cursor).chain().in_set(KbInput),
+            (
+                (kb_move_cursor, kb_input_text, kb_clipboard, reshape)
+                    .chain()
+                    .in_set(KbInput),
+                blink_cursor,
+            ),
         )
         .add_systems(
             PostUpdate,
@@ -248,6 +256,7 @@ impl Plugin for CosmicEditPlugin {
                 render_texture,
             )
                 .chain()
+                .in_set(Render)
                 .after(TransformSystem::TransformPropagate),
         )
         .init_resource::<FocusedWidget>()
@@ -284,8 +293,8 @@ impl Plugin for CosmicEditPlugin {
 }
 
 fn add_feature_plugins(app: &mut App) -> &mut App {
-    #[cfg(feature = "placeholder")]
     app.add_plugins(plugins::placeholder::PlaceholderPlugin);
+    app.add_plugins(plugins::password::PasswordPlugin);
 
     app
 }
diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs
index 2c07c75b01ca1a0c47da5a42c846fcb4bd87a1d0..08a7f609753d4e9fc21c3d47d18b1c76649df80d 100644
--- a/src/plugins/mod.rs
+++ b/src/plugins/mod.rs
@@ -1,2 +1,2 @@
-#[cfg(feature = "placeholder")]
+pub mod password;
 pub mod placeholder;
diff --git a/src/plugins/password/mod.rs b/src/plugins/password/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..a36109c8e5bfc303172a59f6a326a95ce4863983
--- /dev/null
+++ b/src/plugins/password/mod.rs
@@ -0,0 +1,200 @@
+use crate::{buffer::BufferExtras, placeholder::Placeholder};
+use bevy::prelude::*;
+use cosmic_text::{Cursor, Edit, Selection, Shaping};
+use unicode_segmentation::UnicodeSegmentation;
+
+use crate::{
+    input::{input_mouse, kb_input_text, kb_move_cursor},
+    CosmicBuffer, CosmicEditor, CosmicFontSystem, DefaultAttrs, Render,
+};
+
+pub struct PasswordPlugin;
+
+#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
+pub struct PasswordSet;
+
+impl Plugin for PasswordPlugin {
+    fn build(&self, app: &mut App) {
+        app.add_systems(PreUpdate, (hide_password_text.before(input_mouse),))
+            .add_systems(
+                Update,
+                (restore_password_text
+                    .before(kb_input_text)
+                    .after(kb_move_cursor),),
+            )
+            .add_systems(
+                PostUpdate,
+                (
+                    hide_password_text.before(Render).in_set(PasswordSet),
+                    restore_password_text.after(Render),
+                ),
+            );
+    }
+}
+
+#[derive(Component)]
+pub struct Password {
+    real_text: String,
+    glyph: char,
+}
+
+impl Default for Password {
+    fn default() -> Self {
+        Self {
+            real_text: Default::default(),
+            glyph: '●',
+        }
+    }
+}
+
+fn hide_password_text(
+    mut q: Query<(
+        &mut Password,
+        &mut CosmicBuffer,
+        &DefaultAttrs,
+        Option<&mut CosmicEditor>,
+        Option<&Placeholder>,
+    )>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (mut password, mut buffer, attrs, editor_opt, placeholder_opt) in q.iter_mut() {
+        if let Some(placeholder) = placeholder_opt {
+            if placeholder.is_active() {
+                continue;
+            }
+        }
+        if let Some(mut editor) = editor_opt {
+            let mut cursor = editor.cursor();
+            let mut selection = editor.selection();
+
+            editor.with_buffer_mut(|buffer| {
+                let text = buffer.get_text();
+
+                // Translate cursor to correct position for blocker glyphs
+                let translate_cursor = |c: &mut Cursor| {
+                    let (pre, _post) = text.split_at(c.index);
+                    let graphemes = pre.graphemes(true).count();
+                    c.index = graphemes * password.glyph.len_utf8();
+                };
+
+                translate_cursor(&mut cursor);
+
+                // Translate selection cursor
+                match selection {
+                    Selection::None => {}
+                    Selection::Line(ref mut c) => {
+                        translate_cursor(c);
+                    }
+                    Selection::Word(ref mut c) => {
+                        translate_cursor(c);
+                    }
+                    Selection::Normal(ref mut c) => {
+                        translate_cursor(c);
+                    }
+                }
+
+                // Update text to blockers
+                buffer.set_text(
+                    &mut font_system,
+                    password
+                        .glyph
+                        .to_string()
+                        .repeat(text.graphemes(true).count())
+                        .as_str(),
+                    attrs.as_attrs(),
+                    Shaping::Advanced,
+                );
+
+                password.real_text = text;
+            });
+
+            editor.set_cursor(cursor);
+            editor.set_selection(selection);
+
+            continue;
+        }
+
+        let text = buffer.get_text();
+
+        buffer.set_text(
+            &mut font_system,
+            password.glyph.to_string().repeat(text.len()).as_str(),
+            attrs.as_attrs(),
+        );
+        password.real_text = text;
+    }
+}
+
+fn restore_password_text(
+    mut q: Query<(
+        &Password,
+        &mut CosmicBuffer,
+        &DefaultAttrs,
+        Option<&mut CosmicEditor>,
+        Option<&Placeholder>,
+    )>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (password, mut buffer, attrs, editor_opt, placeholder_opt) in q.iter_mut() {
+        if let Some(placeholder) = placeholder_opt {
+            if placeholder.is_active() {
+                continue;
+            }
+        }
+        if let Some(mut editor) = editor_opt {
+            let mut cursor = editor.cursor();
+            let mut selection = editor.selection();
+
+            editor.with_buffer_mut(|buffer| {
+                let text = buffer.get_text();
+
+                // Find cursor position and translate back to correct position in real text
+                let restore_cursor = |c: &mut Cursor| {
+                    let (pre, _post) = text.split_at(c.index);
+                    let graphemes = pre.graphemes(true).count();
+                    let mut n_i = 0;
+                    if let Some((i, _)) = password.real_text.grapheme_indices(true).nth(graphemes) {
+                        n_i = i;
+                    } else if c.index > 0 {
+                        n_i = password.real_text.len();
+                    }
+                    c.index = n_i;
+                };
+
+                restore_cursor(&mut cursor);
+
+                // Translate selection cursor
+                match selection {
+                    Selection::None => {}
+                    Selection::Line(ref mut c) => {
+                        restore_cursor(c);
+                    }
+                    Selection::Word(ref mut c) => {
+                        restore_cursor(c);
+                    }
+                    Selection::Normal(ref mut c) => {
+                        restore_cursor(c);
+                    }
+                }
+
+                buffer.set_text(
+                    &mut font_system,
+                    password.real_text.as_str(),
+                    attrs.as_attrs(),
+                    Shaping::Advanced,
+                );
+            });
+
+            editor.set_cursor(cursor);
+            editor.set_selection(selection);
+
+            continue;
+        }
+
+        buffer.set_text(
+            &mut font_system,
+            password.real_text.as_str(),
+            attrs.as_attrs(),
+        );
+    }
+}
diff --git a/src/plugins/placeholder/mod.rs b/src/plugins/placeholder/mod.rs
index 65d7b704b31b092676722bda9eb64a5c0c4d6678..a1807795af188ba9753dd358b753d691880e791c 100644
--- a/src/plugins/placeholder/mod.rs
+++ b/src/plugins/placeholder/mod.rs
@@ -1,3 +1,4 @@
+use crate::{buffer::BufferExtras, Render};
 use bevy::prelude::*;
 use cosmic_text::{Attrs, Edit};
 
@@ -20,6 +21,10 @@ impl Placeholder {
             attrs,
         }
     }
+
+    pub fn is_active(&self) -> bool {
+        self.active
+    }
 }
 
 pub struct PlaceholderPlugin;
@@ -35,7 +40,8 @@ impl Plugin for PlaceholderPlugin {
                 remove_placeholder_on_input,
             )
                 .chain()
-                .after(KbInput),
+                .after(KbInput)
+                .before(Render),
         );
     }
 }