เอฟเฟกต์พิมพ์ดีด (Typewriter effect) คือสิ่งที่เทียบเท่ากับการเฝ้าดูใครบางคนกำลังคิดดังๆ ในโลกดิจิทัล ข้อความจะปรากฏขึ้นทีละตัวอักษร ราวกับว่ามีมือจริงๆ กำลังกดปุ่มพิมพ์จริงๆ คุณจะเห็นมันได้ในส่วน Hero ของเว็บไซต์พอร์ตโฟลิโอ, ในโปรแกรมจำลองเทอร์มินัล (terminal emulators) บนเบราว์เซอร์ และในหน้าต่างแชทของ AI assistant ที่ต้องการพิสูจน์ว่าพวกเขากำลัง "พิมพ์" คำตอบออกมาจริงๆ ไม่ใช่แค่ดึงข้อความสำเร็จรูปมาแสดง เมื่อทำออกมาได้ดี มันจะสร้างความรู้สึกน่าติดตาม แต่ถ้าทำออกมาไม่ดี มันจะให้ความรู้สึกเหมือนเครื่องพิมพ์ที่กระดาษติดในปี 1987
ทำไมรูปแบบนี้ถึงยังคงอยู่
คอมพิวเตอร์ส่งข้อมูลได้ทันที แต่คนเราไม่ใช่ ช่องว่างระหว่างความเร็วทั้งสองแบบนี้มีประโยชน์ เอฟเฟกต์พิมพ์ดีดช่วยเชื่อมช่องว่างนั้นด้วยการจำลองจังหวะการทำงานของมนุษย์ ในหน้า Landing page มันสามารถดึงสายตาไปยังหัวข้อข่าวทีละคำ เพื่อให้ผู้เข้าชมได้อ่านคุณค่าที่นำเสนอ (value proposition) จริงๆ แทนที่จะแค่กวาดสายตาผ่าน ในโปรแกรมจำลองเทอร์มินัล มันช่วยสร้างภาพลวงตาว่าคำสั่งกำลังทำงานแบบเรียลไทม์ และในอินเทอร์เฟซของแชทบอท จังหวะการพิมพ์จะส่งสัญญาณว่าคำตอบกำลังถูกสร้างขึ้นสดๆ ไม่ใช่แค่การดึงข้อมูลมาจากฐานข้อมูล
แต่เอฟเฟกต์นี้จะทำงานได้ดีก็ต่อเมื่อกลไกของมันเคารพผู้ใช้งาน จังหวะการพิมพ์ที่สม่ำเสมอเหมือนเสียงเครื่องให้จังหวะ (metronome) ที่ดัง ติ๊ก-ติ๊ก-ติ๊ก เท่ากันทุกครั้งจะให้ความรู้สึกเหมือนหุ่นยนต์ ยิ่งไปกว่านั้น การเขียนโปรแกรมที่ละเลยเทคโนโลยีสิ่งอำนวยความสะดวก (assistive technology) อาจเปลี่ยนลูกเล่นทางภาพที่น่าสนุกให้กลายเป็นอุปสรรคที่น่าหงุดหงิด เป้าหมายไม่ใช่การทำให้ผู้ใช้ช้าลง แต่คือการเพิ่มแรงเสียดทาน (friction) ให้พอเหมาะเพื่อให้อินเทอร์เฟซดูมีชีวิตชีวา
สร้างมันด้วย Recursive setTimeout
เริ่มต้นด้วย setTimeout และอย่าไปยุ่งกับ setInterval ความแตกต่างนั้นสำคัญกว่าที่คุณคิด
setInterval นั้นดื้อรั้น มันจะทำงานทุกๆ n มิลลิวินาที โดยไม่สนใจว่าเกิดอะไรขึ้นในสคริปต์ของคุณหรือใน main thread ของเบราว์เซอร์ หากตรรกะของคุณต้องการการหยุดพัก 50 มิลลิวินาทีระหว่างการพิมพ์ตัวอักษรปกติ แต่ต้องการหยุดพัก 150 มิลลิวินาทีหลังเครื่องหมายวรรคตอน setInterval จะไม่สามารถปรับตัวได้ คุณจะลงเอยด้วยการต้องเขียนตรรกะเงื่อนไขเพิ่มเติมเพื่อควบคุมมัน ต้องสู้กับปัญหา race conditions และสุดท้ายต้องคอยสั่ง clear และ reset interval บ่อยเสียจนโค้ดกลายเป็นฝันร้ายของการจัดการสถานะ (state management) ช่วงเวลาที่คงที่ (fixed intervals) จะล้มเหลวทันทีที่คุณต้องการความเร็วที่แปรผันได้
Recursive setTimeout แก้ปัญหานี้ได้โดยการปล่อยให้แต่ละขั้นตอนเป็นตัวกำหนดกฎสำหรับขั้นตอนถัดไป ให้คิดซะว่ามันคือเครื่องจักรสถานะ (state machine) ขนาดเล็ก คุณจะรักษาตัวแปรไว้ไม่กี่ตัว ได้แก่: ข้อความปัจจุบัน, ดัชนีตัวอักษรปัจจุบัน, flag แบบ boolean เพื่อบอกว่ากำลังพิมพ์หรือกำลังลบ และตัวนับ textIndex เพื่อให้คุณสามารถวนลูปผ่านข้อความหลายชุดได้ ฟังก์ชันจะเพิ่มตัวอักษรทีละหนึ่งตัว ตรวจสอบว่าตำแหน่งนั้นอยู่ในประโยคตรงไหน จากนั้นจึงกำหนดเวลาเรียกใช้งานตัวเองครั้งต่อไปด้วยความล่าช้า (delay) ที่เหมาะสมกับบริบทนั้นๆ
ตัวอย่างเช่น คุณอาจจะพิมพ์ตัวอักษรส่วนใหญ่ด้วยความเร็ว 50 มิลลิวินาที ช้าลงเป็น 150 มิลลิวินาทีหลังเครื่องหมายจุลภาค และหยุดพัก 800 มิลลิวินาทีเมื่อจบประโยคก่อนที่จะเปลี่ยนเป็นโหมดลบ คุณไม่สามารถทำแบบนั้นได้อย่างสะอาดหมดจดด้วย setInterval แต่ด้วย recursive setTimeout ตรรกะจะเรียบง่ายมาก:
if typing:
append next character
if at end of string:
switch to pause mode
schedule next call after 1000ms
if deleting:
remove last character
if string empty:
increment textIndex
load next string
switch to typing mode
โครงสร้างนี้ยังทำให้การทำความสะอาด (cleanup) เป็นเรื่องง่าย เพียงแค่เก็บค่า timeout ID ไว้ เมื่อคอมโพเนนต์ถูก unmount หรือผู้ใช้เปลี่ยนหน้า ให้เรียก clearTimeout เพียงครั้งเดียว ก็จะไม่มี interval ที่ค้างอยู่ทำงานอยู่เบื้องหลัง
เคอร์เซอร์ควรจะกะพริบด้วยตัวมันเอง
เคอร์เซอร์ที่กะพริบเป็นรายละเอียดทางภาพ ไม่ใช่เรื่องของข้อมูล อย่าเอาไปรวมไว้ใน JavaScript state engine ของคุณ ให้ใช้ CSS animation แยกต่างหากที่ผูกไว้กับ pseudo-element ::after หรือใช้ <span เฉพาะเจาะจงที่วางไว้ท้ายคอนเทนเนอร์ข้อความของคุณ
การใช้ @keyframes blink ง่ายๆ เพื่อสลับ opacity หรือ border-color ด้วยจังหวะแบบ step-end จะช่วยให้คุณได้จังหวะการกะพริบที่คมชัดและเป็นมิตรต่อฮาร์ดแวร์ ซึ่งทำงานบน compositor JavaScript ไม่ควรเข้าไปจัดการเรื่องการมองเห็นของเคอร์เซอร์ หากคุณไปสลับคุณสมบัติการแสดงผล (display properties) จากภายใน recursion ของ setTimeout คุณจะบังคับให้เกิดการคำนวณสไตล์ใหม่ (style recalculations) โดยไม่จำเป็นในทุกๆ ตัวอักษร ปล่อยให้ CSS จัดการเรื่องความสวยงาม และปล่อยให้ JavaScript จัดการเรื่องลำดับขั้นตอน
การวนลูปผ่านข้อความหลายชุดสามารถทำได้ง่ายๆ ด้วยตัวนับ textIndex โดยเก็บข้อความของคุณไว้ใน array เมื่อแอนิเมชันจบช่วงการลบและคอนเทนเนอร์ว่างเปล่า ให้เพิ่มค่า textIndex โดยใช้ modulo กับความยาวของ array, รีเซ็ตตัวชี้ตัวอักษรเป็นศูนย์ และเริ่มพิมพ์ใหม่อีกครั้ง นี่คือวิธีที่เว็บไซต์พอร์ตโฟลิโอใช้ในการวนลูปแสดงบทบาทต่างๆ เช่น ["Developer", "Designer", "Writer"] โดยไม่ต้องโหลดหน้าเว็บใหม่เลย
ข้อผิดพลาดที่ทำลายภาพลวงตา
มีข้อผิดพลาดสามประการที่มักปรากฏให้เห็นซ้ำแล้วซ้ำเล่าในการเขียนโปรแกรมแบบมือสมัครเล่น
การใช้ setInterval. เราได้พูดถึงปัญหาความเร็วที่ไม่คงที่ไปแล้ว แต่ยังมีปัญหาที่ละเอียดอ่อนกว่านั้น หากการอัปเดต DOM ของคุณเกิดการหน่วง—เช่น เพราะเบราว์เซอร์กำลังวาด layout shift—setInterval จะยังคงทำงานต่อไป คุณอาจพบปัญหาการเขียนข้อมูลทับซ้อนกัน ตัวอักษรซ้ำ หรือการเขียนที่เกิดขึ้นเร็วกว่าที่เบราว์เซอร์จะเรนเดอร์ได้ ในขณะที่ setTimeout แบบเรียกซ้ำ (Recursive) จะรอจนกว่าขั้นตอนปัจจุบันจะเสร็จสิ้นก่อนที่จะเริ่มขั้นตอนถัดไป
การลืม escape HTML. หากสตริงต้นฉบับของคุณมีเครื่องหมายวงเล็บแหลม และคุณกำลังฉีดเนื้อหาผ่าน innerHTML ทีละตัวอักษร คุณจะทำให้แท็กถูกแยกออกจากกันครึ่งหนึ่ง เบราว์เซอร์จะเห็น <, จากนั้นเป็น <s, แล้วตามด้วย <st สิ่งนี้จะขัดขวางการวิเคราะห์แท็ก (tag parsing) ที่ถูกต้อง และอาจทำให้เกิดโหนด DOM ที่เสียหายหรือการส่งต่อสไตล์ (styling cascades) ที่ไม่คาดคิด หากคุณต้องการให้แสดงเครื่องหมายเหล่านี้ตามจริง ให้ทำการ escape ก่อน หรือทางที่ดีกว่าคือเขียนลงใน textContent แทนที่จะเป็น innerHTML หากคุณจำเป็นต้องใช้ <span> ที่มีการจัดสไตล์ภายในผลลัพธ์ของ typewriter จริงๆ ให้ประมวลผลสตริงล่วงหน้าเพื่อให้คุณทราบตำแหน่งที่แน่นอนของจุดเริ่มต้นและจุดสิ้นสุดของแท็กก่อนที่จะเริ่มลูปตัวอักษร
การละเลยเรื่องการเข้าถึง (Accessibility). โปรแกรมอ่านหน้าจอ (Screen readers) ไม่ชอบการถูกอ่านทีละตัวอักษร เมื่อสคริปต์ของคุณเพิ่มตัวอักษรใหม่ลงใน DOM เทคโนโลยีช่วยเหลือบางอย่างจะประกาศเนื้อหาในโหนดนั้นทั้งหมดซ้ำอีกครั้ง ทำให้เกิดเสียงอ่านคำที่ขาดๆ หายๆ ต่อเนื่องกันเหมือนจังหวะสแตคคาโต ซึ่งเป็นฝันร้ายสำหรับผู้ที่ต้องนำทางด้วยเสียง วิธีแก้ไขนั้นไม่ซับซ้อน: ให้เพิ่ม aria-label ไปที่คอนเทนเนอร์ที่เก็บข้อความฉบับสมบูรณ์และสุดท้ายไว้ คุณยังสามารถซ่อนองค์ประกอบที่มีแอนิเมชันจากเทคโนโลยีช่วยเหลือได้โดยใช้ aria-hidden="true" และจัดเตรียมสำเนาแบบคงที่ที่ซ่อนไว้ทางสายตาสำหรับโปรแกรมอ่านหน้าจอแทน ไม่ว่าจะวิธีใดก็ตาม ควรให้ประโยคที่สมบูรณ์แก่ผู้ใช้ตั้งแต่แรก แทนที่จะบังคับให้พวกเขาต้องนั่งฟังการแสดงของคุณจนจบ
วิธีการขัดเกลาเวอร์ชันของคุณ
เมื่อลูปหลักทำงานได้ถูกต้องแล้ว คุณสามารถเพิ่มลูกเล่นเสริมเข้าไปได้ แต่ขอให้ยับยั้งชั่งใจที่จะไม่เพิ่มจนกว่าพื้นฐานจะแน่นพอ
เอฟเฟกต์เสียงการพิมพ์. เสียงคลิกเบาๆ ในแต่ละตัวอักษรอาจให้ความรู้สึกที่น่าพึงพอใจ แต่เสียงบนหน้าเว็บนั้นเป็นเรื่องที่ต้องระวังอย่างมาก ให้ใช้ Web Audio API หรือองค์ประกอบ Audio ที่มีน้ำหนักเบาพร้อมบัฟเฟอร์สั้นๆ ลองปรับอัตราการเล่น (playback rate) เล็กน้อย—ระหว่าง 0.95 ถึง 1.05—เพื่อให้เสียงคลิกที่เหมือนกันไม่ฟังดูเหมือนเสียงสังเคราะห์เสมอไป และต้องเคารพนโยบายการเล่นอัตโนมัติ (autoplay policies) ของเบราว์เซอร์ พร้อมทั้งมีปุ่มเปิด/ปิดเสียง (mute toggle) ให้ด้วย ไม่มีอะไรที่จะทำให้ผู้ใช้หนีไปได้เร็วเท่ากับหน้าพอร์ตโฟลิโอที่เปิดเสียงพิมพ์อัตโนมัติในเวลา 9 โมงเช้า
การพิมพ์แบบหลายบรรทัด. หน้าต่างเทอร์มินัลจริงๆ จะมีการตัดบรรทัด (wrap) หากข้อความของคุณข้ามบรรทัด เคอร์เซอร์แบบ border-right แบบง่ายๆ จะกระโดดอย่างน่าเกลียด เว้นแต่ว่าเลย์เอาต์ของคุณจะคาดเดาได้ ให้แยกสตริงด้วยตัวอักษรขึ้นบรรทัดใหม่และเรนเดอร์แต่ละบรรทัดใน <span> ของตัวเอง หรือใช้ pseudo-element ที่กำหนดตำแหน่งเพื่อติดตามจุดสิ้นสุดของเนื้อหา ระวังเรื่องการตัดคำ (text wrapping) ด้วย เพราะเคอร์เซอร์ที่ทำจาก inline border อาจหลุดออกจากข้อความหากความกว้างของคอนเทนเนอร์เปลี่ยนไป ลองพิจารณาใช้ white-space: pre-wrap และฟอนต์แบบ monospaced สำหรับสไตล์เทอร์มินัล เนื่องจากตัวอักษรที่มีความกว้างคงที่จะทำให้การคำนวณตำแหน่งเคอร์เซอร์คาดเดาได้ง่ายกว่ามาก
การเรนเดอร์ Markdown แบบเรียลไทม์. นี่คือจุดที่เริ่มยาก หากคุณพิมพ์ **bold** คุณมีทางเลือกสองทาง: คือเรนเดอร์เครื่องหมายดอกจันตามจริงที่ปรากฏ หรือแปลงพวกมันเป็นสไตล์ตัวหนาในทันที หากคุณเลือกอย่างหลัง การเปลี่ยนจาก textContent เป็น innerHTML ระหว่างทางหมายความว่าขอบเขตของ text node จะเปลี่ยนไป ตำแหน่งของเคอร์เซอร์จะกลายเป็นปัญหาที่น่าปวดหัวในการจัดการข้อมูล เพราะแท็ก HTML จะทำให้โครงสร้าง DOM tree เปลี่ยนไป วิธีที่ปลอดภัยกว่าคือการพิมพ์สตริง Markdown ดิบตามปกติ จากนั้นจึงสั่งให้เกิดการเรนเดอร์ (render pass) หนึ่งครั้งเมื่อสตริงทั้งหมดปรากฏบนหน้าจอแล้ว หากคุณต้องการการจัดรูปแบบแบบสดๆ จริงๆ ให้รักษาไว้สองเลเยอร์: บัฟเฟอร์ที่ถูกพิมพ์แบบซ่อนไว้ และเลเยอร์การแสดงผลที่ผ่านการ parse แล้ว
เอฟเฟกต์ย้อนกลับ. การลบข้อความไม่จำเป็นต้องหมายถึงการกด backspace ทีละตัวอักษรเสมอไป คุณสามารถจำลองการรีเซ็ตแบบ "เลือกทั้งหมดแล้วลบ" ซึ่งจะล้างฟิลด์ทิ้งทันทีก่อนที่สตริงถัดไปจะเริ่มพิมพ์ ซึ่งจะให้ความรู้สึกที่ดูแข็งทื่อ หรืออีกทางเลือกหนึ่งคือการกด backspace ช้าๆ ที่ 30 มิลลิวินาทีต่อตัวอักษรเพื่อสร้างความรู้สึกลุ้นระทึก ลองผสมผสานทั้งสองแบบดู: กด backspace ลบคำที่พิมพ์ผิดอย่างรวดเร็ว หยุดพัก แล้วจึงเริ่มลบด้วยความเร็วปกติ การสร้างความหลากหลายจะช่วยให้เอฟเฟกต์ดูมีความเป็นธรรมชาติมากขึ้น
บทสรุปที่แท้จริง
เอฟเฟกต์ typewriter เป็นหนึ่งในลูกเล่น UI ที่ดูเหมือนจะง่ายในตอนแรก แต่จะเผยความซับซ้อนออกมาหลังจากที่คุณสร้างมันขึ้นมาแล้ว เริ่มต้นด้วย
