diff --git a/Cargo.lock b/Cargo.lock
index a81e041111e8bcbad0164627fa425d43d2b3c2d3..f630092a0b8ebad722e786b8f507cd8241efdb34 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -393,10 +393,14 @@ dependencies = [
  "arboard",
  "bevy",
  "cosmic-text",
+ "crossbeam-channel",
  "image",
  "insta",
  "js-sys",
  "sys-locale",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 5fb3fba90322beeca5aab26566ea5ea76265a089..618ecea50cc35afad827751ca44823091849a813 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,8 @@ bevy = { version = "0.11", default-features = false, features = [
   "x11",
 ] }
 cosmic-text = { version = "0.9" }
+# TODO: move crossbeam to wasm32, once input.rs has separate wasm copy/paste fn
+crossbeam-channel = "0.5.8"
 image = "0.24.6"
 sys-locale = "0.3.0"
 
@@ -41,9 +43,12 @@ arboard = "3.2.0"
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 js-sys = "0.3.61"
+wasm-bindgen = "0.2.87"
+wasm-bindgen-futures = "0.4.37"
+web-sys = { version = "0.3.64", features = ["Clipboard", "Navigator", "Window"] }
 
 [dev-dependencies]
 insta = "1.29.0"
 
 [[example]]
-name = "text_input"
\ No newline at end of file
+name = "text_input"
diff --git a/src/input.rs b/src/input.rs
index 8d441010f2ea3a4fc958bbb411ef618e6ecce3b5..4110a3f5f60423baa2fe67efa16230fca0de253d 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -2,6 +2,9 @@
 
 use std::time::Duration;
 
+#[cfg(target_arch = "wasm32")]
+use bevy::tasks::AsyncComputeTaskPool;
+
 use bevy::{
     input::mouse::{MouseMotion, MouseScrollUnit, MouseWheel},
     prelude::*,
@@ -9,6 +12,13 @@ use bevy::{
 };
 use cosmic_text::{Action, AttrsList, BufferLine, Cursor, Edit, Shaping};
 
+#[cfg(target_arch = "wasm32")]
+use js_sys::Promise;
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen::prelude::*;
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen_futures::JsFuture;
+
 use crate::{
     get_node_cursor_pos, get_timestamp, get_x_offset_center, get_y_offset_center,
     save_edit_history, CosmicAttrs, CosmicEditHistory, CosmicEditor, CosmicFontSystem,
@@ -19,6 +29,19 @@ use crate::{
 #[derive(Resource)]
 pub struct ClickTimer(pub Timer);
 
+// TODO: hide this behind #cfg wasm, depends on wasm having own copy/paste fn
+#[allow(dead_code)]
+pub struct WasmPaste {
+    text: String,
+    entity: Entity,
+}
+
+#[derive(Resource)]
+pub struct WasmPasteAsyncChannel {
+    pub tx: crossbeam_channel::Sender<WasmPaste>,
+    pub rx: crossbeam_channel::Receiver<WasmPaste>,
+}
+
 pub(crate) fn input_mouse(
     windows: Query<&Window, With<PrimaryWindow>>,
     active_editor: Res<Focus>,
@@ -189,6 +212,8 @@ pub(crate) fn input_mouse(
     }
 }
 
+// TODO: split copy/paste into own fn, separate fn for wasm
+// Maybe split undo/redo too, just drop inputs from this fn when pressed
 /// Handles undo/redo, copy/paste and char input
 pub(crate) fn input_kb(
     active_editor: Res<Focus>,
@@ -208,6 +233,7 @@ pub(crate) fn input_kb(
     mut is_deleting: Local<bool>,
     mut edits_duration: Local<Option<Duration>>,
     mut undoredo_duration: Local<Option<Duration>>,
+    _channel: Option<Res<WasmPasteAsyncChannel>>,
 ) {
     for (mut editor, mut edit_history, attrs, max_lines, max_chars, entity, readonly_opt) in
         &mut cosmic_edit_query.iter_mut()
@@ -228,6 +254,19 @@ pub(crate) fn input_kb(
         #[cfg(not(target_os = "macos"))]
         let command = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
 
+        #[cfg(target_arch = "wasm32")]
+        let command = if web_sys::window()
+            .unwrap()
+            .navigator()
+            .user_agent()
+            .unwrap_or("NoUA".into())
+            .contains("Macintosh")
+        {
+            keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight])
+        } else {
+            command
+        };
+
         let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
 
         #[cfg(target_os = "macos")]
@@ -479,6 +518,40 @@ pub(crate) fn input_kb(
             }
         }
 
+        #[cfg(target_arch = "wasm32")]
+        {
+            if command && keys.just_pressed(KeyCode::C) {
+                if let Some(text) = editor.0.copy_selection() {
+                    write_clipboard_wasm(text.as_str());
+                    return;
+                }
+            }
+
+            if command && keys.just_pressed(KeyCode::X) && !readonly {
+                if let Some(text) = editor.0.copy_selection() {
+                    write_clipboard_wasm(text.as_str());
+                    editor.0.delete_selection();
+                }
+                is_clipboard = true;
+            }
+            if command && keys.just_pressed(KeyCode::V) && !readonly {
+                let tx = _channel.unwrap().tx.clone();
+                let _task = AsyncComputeTaskPool::get().spawn(async move {
+                    let promise = read_clipboard_wasm();
+
+                    let result = JsFuture::from(promise).await;
+
+                    if let Ok(js_text) = result {
+                        if let Some(text) = js_text.as_string() {
+                            let _ = tx.try_send(WasmPaste { text, entity });
+                        }
+                    }
+                });
+
+                return;
+            }
+        }
+
         let mut is_edit = is_clipboard;
         let mut is_return = false;
         if keys.just_pressed(KeyCode::Return) && !readonly {
@@ -505,7 +578,7 @@ pub(crate) fn input_kb(
                         }
                     }
                     editor.0.action(&mut font_system.0, Action::Backspace);
-                } else if max_chars.0 == 0 || editor.get_text().len() < max_chars.0 {
+                } else if !command && (max_chars.0 == 0 || editor.get_text().len() < max_chars.0) {
                     editor
                         .0
                         .action(&mut font_system.0, Action::Insert(char_ev.char));
@@ -532,3 +605,70 @@ pub(crate) fn input_kb(
         }
     }
 }
+
+#[cfg(target_arch = "wasm32")]
+#[wasm_bindgen]
+pub fn write_clipboard_wasm(text: &str) {
+    let clipboard = web_sys::window()
+        .unwrap()
+        .navigator()
+        .clipboard()
+        .expect("Clipboard not found!");
+    let _result = clipboard.write_text(text);
+}
+
+#[cfg(target_arch = "wasm32")]
+#[wasm_bindgen]
+pub fn read_clipboard_wasm() -> Promise {
+    let clipboard = web_sys::window()
+        .unwrap()
+        .navigator()
+        .clipboard()
+        .expect("Clipboard not found!");
+    clipboard.read_text()
+}
+
+#[cfg(target_arch = "wasm32")]
+pub fn poll_wasm_paste(
+    channel: Res<WasmPasteAsyncChannel>,
+    mut editor_q: Query<
+        (
+            &mut CosmicEditor,
+            &CosmicAttrs,
+            &CosmicMaxChars,
+            &CosmicMaxChars,
+            &mut CosmicEditHistory,
+        ),
+        Without<ReadOnly>,
+    >,
+    mut evw_changed: EventWriter<CosmicTextChanged>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    let inlet = channel.rx.try_recv();
+    match inlet {
+        Ok(inlet) => {
+            let entity = inlet.entity;
+            if let Ok((mut editor, attrs, max_chars, max_lines, mut edit_history)) =
+                editor_q.get_mut(entity)
+            {
+                let text = inlet.text;
+                let attrs = &attrs.0;
+                for c in text.chars() {
+                    if max_chars.0 == 0 || editor.get_text().len() < max_chars.0 {
+                        if c == 0xA as char {
+                            if max_lines.0 == 0 || editor.0.buffer().lines.len() < max_lines.0 {
+                                editor.0.action(&mut font_system.0, Action::Insert(c));
+                            }
+                        } else {
+                            editor.0.action(&mut font_system.0, Action::Insert(c));
+                        }
+                    }
+                }
+
+                evw_changed.send(CosmicTextChanged((entity, editor.get_text())));
+                save_edit_history(&mut editor.0, attrs, &mut edit_history);
+            }
+        }
+        Err(_) => {}
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index cb9113f2ba8b10ad3c6ad1a44ef13b3f5b8653f0..eff2c36648975aad2cd31c6c3487c4e52d441166 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -24,6 +24,8 @@ use cursor::{change_cursor, hover_sprites, hover_ui};
 pub use cursor::{TextHoverIn, TextHoverOut};
 use image::{imageops::FilterType, GenericImageView};
 use input::{input_kb, input_mouse, ClickTimer};
+#[cfg(target_arch = "wasm32")]
+use input::{poll_wasm_paste, WasmPaste, WasmPasteAsyncChannel};
 
 #[derive(Clone, Component, PartialEq, Debug)]
 pub enum CosmicText {
@@ -457,6 +459,13 @@ impl Plugin for CosmicEditPlugin {
             }
             CursorConfig::None => {}
         }
+
+        #[cfg(target_arch = "wasm32")]
+        {
+            let (tx, rx) = crossbeam_channel::bounded::<WasmPaste>(1);
+            app.insert_resource(WasmPasteAsyncChannel { tx, rx })
+                .add_systems(Update, poll_wasm_paste);
+        }
     }
 }