| name | emoji-encoder |
| description | Use when implementing steganographic text encoding using Unicode Variation Selectors - allows encoding any message invisibly into emojis, letters, or any characters using Unicode Variation Selectors (U+FE00-U+FE0F and U+E0100-U+E01EF) |
| when_to_use | When adding steganographic encoding, hiding data in plain text, encoding messages into emojis/characters, or implementing invisible data encoding using Unicode Variation Selectors |
Emoji Encoder - Steganographic Text Encoding
Overview
Complete implementation guide for encoding any text message invisibly into emojis, letters, or any Unicode characters using Unicode Variation Selectors. The encoded data appears as just the marker character (emoji/letter) followed by invisible variation selector characters, making it perfect for steganographic applications.
Core Capabilities:
- Encode any UTF-8 text into invisible Unicode Variation Selectors
- Use any character as a marker (emojis, letters, symbols)
- Decode hidden messages from encoded text
- Detect if text contains encoded data
- React hooks for easy integration
- Zero external dependencies - pure TypeScript/JavaScript
How It Works
The encoding uses Unicode Variation Selectors to invisibly encode UTF-8 bytes:
- Bytes 0-15: Encoded as Variation Selectors (U+FE00-U+FE0F)
- Bytes 16-255: Encoded as Variation Selectors Supplement (U+E0100-U+E01EF)
- Marker: Any Unicode character (emoji, letter, symbol, number, punctuation, etc.) appears before encoded data
- Result: Appears as just the marker character, with invisible encoded data following
CRITICAL: The marker can be any Unicode character - there are no restrictions. Use emojis, letters, numbers, symbols, or any other Unicode character that fits your use case.
Example:
Input: "Hello, World!"
Marker: "π₯"
Output: "π₯" + [invisible variation selectors]
The output looks like just "π₯" but contains the full encoded message.
Prerequisites
IMPORTANT: Before adding dependencies, review your project's package.json to check if any of these packages already exist. If they do, verify the versions are compatible with the requirements below. Only add packages that are missing or need version updates.
Required packages:
{
"typescript": "^5.x"
}
No external dependencies required - uses only native JavaScript/TypeScript APIs.
Implementation Checklist
- Implement core encoding/decoding functions
- Add React hooks for easy integration
- Create UI components for encoding/decoding
- Add emoji/character selector component
- Implement detection function (isEncoded)
- Add error handling and validation
- Create tests for encoding/decoding
- Add copy-to-clipboard functionality
Part 1: Core Encoding Functions
Unicode Variation Selector Constants
// Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html
// VS1..=VS16
const VARIATION_SELECTOR_START = 0xfe00;
const VARIATION_SELECTOR_END = 0xfe0f;
// Variation selectors supplement https://unicode.org/charts/nameslist/n_E0100.html
// VS17..=VS256
const VARIATION_SELECTOR_SUPPLEMENT_START = 0xe0100;
const VARIATION_SELECTOR_SUPPLEMENT_END = 0xe01ef;
Byte to Variation Selector
export function toVariationSelector(byte: number): string | null {
if (byte >= 0 && byte < 16) {
return String.fromCodePoint(VARIATION_SELECTOR_START + byte);
} else if (byte >= 16 && byte < 256) {
return String.fromCodePoint(VARIATION_SELECTOR_SUPPLEMENT_START + byte - 16);
} else {
return null;
}
}
How it works:
- Bytes 0-15 map to U+FE00-U+FE0F (16 characters)
- Bytes 16-255 map to U+E0100-U+E01EF (240 characters)
- Total coverage: 256 possible byte values
Variation Selector to Byte
export function fromVariationSelector(codePoint: number): number | null {
if (codePoint >= VARIATION_SELECTOR_START && codePoint <= VARIATION_SELECTOR_END) {
return codePoint - VARIATION_SELECTOR_START;
} else if (codePoint >= VARIATION_SELECTOR_SUPPLEMENT_START && codePoint <= VARIATION_SELECTOR_SUPPLEMENT_END) {
return codePoint - VARIATION_SELECTOR_SUPPLEMENT_START + 16;
} else {
return null;
}
}
Reverse mapping:
- U+FE00-U+FE0F β bytes 0-15
- U+E0100-U+E01EF β bytes 16-255
Encode Function
export function encode(marker: string, text: string): string {
// Convert the string to utf-8 bytes
const bytes = new TextEncoder().encode(text);
let encoded = marker;
for (const byte of bytes) {
const selector = toVariationSelector(byte);
if (selector === null) {
throw new Error(`Invalid byte value: ${byte}`);
}
encoded += selector;
}
return encoded;
}
Key points:
- Uses
TextEncoderto convert string to UTF-8 bytes - Marker appears first (visible character)
- Each byte encoded as variation selector (invisible)
- Handles all UTF-8 characters (including Unicode)
Usage:
const encoded = encode('π₯', 'Hello, World!');
// Result: "π₯" + [invisible characters]
Decode Function
export function decode(text: string): string {
let decoded: number[] = [];
const chars = Array.from(text);
for (const char of chars) {
const codePoint = char.codePointAt(0);
if (codePoint === undefined) continue;
const byte = fromVariationSelector(codePoint);
if (byte === null && decoded.length > 0) {
// Stop at first non-variation-selector after decoding started
break;
} else if (byte === null) {
// Skip non-variation-selector characters before decoding starts
continue;
}
decoded.push(byte);
}
if (decoded.length === 0) {
throw new Error('No encoded data found');
}
const decodedArray = new Uint8Array(decoded);
return new TextDecoder().decode(decodedArray);
}
Key points:
- Skips marker character (first non-variation-selector)
- Stops at first non-variation-selector after decoding starts
- Uses
TextDecoderto convert UTF-8 bytes back to string - Handles all UTF-8 characters correctly
Usage:
const decoded = decode(encodedText);
// Result: "Hello, World!"
Part 2: React Hook Implementation
useEmojiEncoding Hook
import { useCallback } from 'react';
export interface EmojiEncodingOptions {
/** Marker/prefix before encoded data (default: 'π₯'). Can be any string, emoji, or empty. */
marker?: string;
}
/**
* Hook for encoding and decoding any string data using Unicode Variation Selectors.
*
* Encodes data invisibly using Unicode Variation Selectors (U+FE00-U+FE0F for bytes 0-15,
* U+E0100-U+E01EF for bytes 16-255). The marker appears as just an emoji followed by invisible characters.
*
* @param options - Configuration options
* @param options.marker - Marker/prefix before encoded data (default: 'π₯')
*
* @example
* ```tsx
* const { encode, decode } = useEmojiEncoding({ marker: 'π' });
* const encoded = encode('secret data');
* const decoded = decode(encoded);
* ```
*/
export function useEmojiEncoding(options: EmojiEncodingOptions = {}) {
const { marker = 'π₯' } = options;
const byteToVariationSelector = useCallback((byteValue: number): string => {
if (byteValue >= 0 && byteValue <= 15) {
return String.fromCodePoint(0xfe00 + byteValue);
}
if (byteValue >= 16 && byteValue <= 255) {
return String.fromCodePoint(0xe0100 + (byteValue - 16));
}
return '';
}, []);
const variationSelectorToByte = useCallback((char: string): number | null => {
const codePoint = char.codePointAt(0);
if (codePoint === undefined) return null;
if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) {
return codePoint - 0xfe00;
}
if (codePoint >= 0xe0100 && codePoint <= 0xe01ef) {
return codePoint - 0xe0100 + 16;
}
return null;
}, []);
const encode = useCallback((data: string): string => {
const bytes = new TextEncoder().encode(data);
return (
marker +
Array.from(bytes)
.map((byte) => byteToVariationSelector(byte))
.join('')
);
}, [marker, byteToVariationSelector]);
const decode = useCallback((encoded: string): string | undefined => {
try {
const decoded: number[] = [];
for (const char of Array.from(encoded)) {
const byteValue = variationSelectorToByte(char);
if (byteValue === null && decoded.length > 0) break;
if (byteValue === null) continue;
decoded.push(byteValue);
}
if (decoded.length === 0) return undefined;
const decodedArray = new Uint8Array(decoded);
return new TextDecoder().decode(decodedArray);
} catch (error) {
console.error('Failed to decode emoji-encoded data:', error);
return undefined;
}
}, [variationSelectorToByte]);
const isEncoded = useCallback((text: string): boolean => {
if (marker && !text.includes(marker)) return false;
const decoded = decode(text);
return decoded !== undefined && decoded.length > 0;
}, [marker, decode]);
return {
encode,
decode,
isEncoded,
};
}
Usage:
function MyComponent() {
const { encode, decode, isEncoded } = useEmojiEncoding({ marker: 'π' });
const handleEncode = () => {
const encoded = encode('secret message');
console.log(encoded); // "π" + invisible characters
};
const handleDecode = () => {
const decoded = decode(encodedText);
console.log(decoded); // "secret message"
};
const checkIfEncoded = () => {
if (isEncoded(someText)) {
console.log('Text contains encoded data!');
}
};
}
Part 3: UI Components
Emoji Selector Component
// components/EmojiSelector.tsx
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface EmojiSelectorProps {
emojiList: string[];
selectedEmoji: string;
onEmojiSelect: (emoji: string) => void;
disabled?: boolean;
}
export function EmojiSelector({
emojiList,
selectedEmoji,
onEmojiSelect,
disabled = false,
}: EmojiSelectorProps) {
return (
<div className="flex flex-wrap gap-2">
{emojiList.map((emoji) => (
<Button
key={emoji}
variant={selectedEmoji === emoji ? 'default' : 'outline'}
size="icon"
onClick={() => onEmojiSelect(emoji)}
disabled={disabled}
className={cn(
'text-xl',
selectedEmoji === emoji && 'ring-2 ring-primary'
)}
>
{emoji}
</Button>
))}
</div>
);
}
Encoder/Decoder Component
// components/EncoderDecoder.tsx
"use client"
import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { CardContent } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react";
import { encode, decode } from "@/lib/encoding";
import { EmojiSelector } from "@/components/EmojiSelector";
// Optional: Provide suggested markers, but users can use ANY Unicode character
import { SUGGESTED_MARKERS } from "@/lib/markers";
export function EncoderDecoder() {
const [mode, setMode] = useState<'encode' | 'decode'>('encode');
const [inputText, setInputText] = useState("");
const [selectedMarker, setSelectedMarker] = useState("π₯");
const [outputText, setOutputText] = useState("");
const [errorText, setErrorText] = useState("");
const [copied, setCopied] = useState(false);
// Convert input whenever it changes
useEffect(() => {
try {
if (mode === 'encode') {
const output = encode(selectedMarker, inputText);
setOutputText(output);
setErrorText("");
} else {
const output = decode(inputText);
setOutputText(output);
setErrorText("");
}
} catch (e) {
setOutputText("");
setErrorText(`Error ${mode === "encode" ? "encoding" : "decoding"}: ${e instanceof Error ? e.message : 'Invalid input'}`);
}
}, [mode, selectedMarker, inputText]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(outputText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
This tool allows you to encode a hidden message into any Unicode character (emoji, letter, symbol, etc.).
You can copy and paste text with a hidden message in it to decode the message.
</p>
<div className="flex items-center justify-center space-x-2">
<Label htmlFor="mode-toggle">Decode</Label>
<Switch
id="mode-toggle"
checked={mode === 'encode'}
onCheckedChange={(checked) => {
setMode(checked ? 'encode' : 'decode');
setInputText("");
}}
/>
<Label htmlFor="mode-toggle">Encode</Label>
</div>
<div className="space-y-2">
<Label htmlFor="input">
{mode === 'encode' ? 'Enter text to encode' : 'Paste encoded text to decode'}
</Label>
<Textarea
id="input"
placeholder={mode === 'encode' ? "Enter text to encode" : "Paste an emoji/character to decode"}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className="min-h-[100px] font-mono"
/>
</div>
{mode === 'encode' && (
<>
<div className="space-y-2">
<Label htmlFor="marker-input">
Marker character (any Unicode character works)
</Label>
<div className="flex gap-2">
<input
id="marker-input"
type="text"
value={selectedMarker}
onChange={(e) => setSelectedMarker(e.target.value || 'π₯')}
placeholder="Enter any character"
className="flex-1 px-3 py-2 border rounded-md"
maxLength={1}
/>
</div>
<p className="text-xs text-muted-foreground">
You can use any Unicode character: emojis, letters, numbers, symbols, etc.
</p>
</div>
{/* Optional: Show suggested markers for convenience */}
<div className="space-y-2">
<Label>Or pick from suggested markers</Label>
<EmojiSelector
onEmojiSelect={setSelectedMarker}
selectedEmoji={selectedMarker}
emojiList={SUGGESTED_MARKERS}
/>
</div>
</>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="output">
{mode === 'encode' ? 'Encoded output' : 'Decoded output'}
</Label>
{outputText && (
<Button
onClick={handleCopy}
size="sm"
variant="ghost"
className="h-8"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
)}
</div>
<Textarea
id="output"
placeholder={`${mode === "encode" ? "Encoded" : "Decoded"} output`}
value={outputText}
readOnly
className="min-h-[100px] font-mono"
/>
</div>
{errorText && (
<div className="text-sm text-destructive text-center p-2 bg-destructive/10 rounded">
{errorText}
</div>
)}
</CardContent>
);
}
Part 4: Marker Characters
CRITICAL: Any Unicode Character Can Be Used as a Marker
The marker character can be any Unicode character - emojis, letters, numbers, symbols, punctuation, or any other Unicode character. There are no restrictions on which character you use as a marker.
Examples of valid markers:
- Emojis:
π₯,π,π,π - Letters:
a,Z,Ξ±,Ξ© - Numbers:
1,0,Ο,β - Symbols:
!,@,#,$,%,&,* - Punctuation:
.,,,;,:,?,! - Any other Unicode character
Implementation:
// You can use any character as a marker
const encoded1 = encode('π₯', 'secret message'); // Emoji marker
const encoded2 = encode('a', 'secret message'); // Letter marker
const encoded3 = encode('!', 'secret message'); // Symbol marker
const encoded4 = encode('Ο', 'secret message'); // Greek letter marker
const encoded5 = encode('β', 'secret message'); // Mathematical symbol marker
// All of these work perfectly - choose whatever marker fits your use case
Why this matters:
- No need to restrict users to predefined lists
- Choose markers that fit your application's context
- Use different markers for different purposes or users
- Support any character your users might want to use
Part 5: Detection and Validation
isEncoded Function
export function isEncoded(text: string, marker?: string): boolean {
// If marker specified, check for its presence
if (marker && !text.includes(marker)) {
return false;
}
try {
const decoded = decode(text);
return decoded !== undefined && decoded.length > 0;
} catch {
return false;
}
}
Usage:
if (isEncoded(someText, 'π₯')) {
console.log('Text contains encoded data with peanut marker');
}
Validation Helper
export function validateEncoded(text: string, marker?: string): {
isValid: boolean;
error?: string;
} {
if (!text || text.length === 0) {
return { isValid: false, error: 'Text is empty' };
}
if (marker && !text.startsWith(marker)) {
return { isValid: false, error: `Text does not start with marker: ${marker}` };
}
try {
const decoded = decode(text);
if (!decoded || decoded.length === 0) {
return { isValid: false, error: 'No encoded data found' };
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error: error instanceof Error ? error.message : 'Decoding failed'
};
}
}
Part 6: Testing
Unit Tests
import { describe, test, expect } from 'vitest';
import { encode, decode, toVariationSelector, fromVariationSelector } from './encoding';
import { EMOJI_LIST, ALPHABET_LIST } from './emoji';
describe('emoji encoder/decoder', () => {
test('should correctly encode and decode strings', () => {
const testStrings = [
'Hello, World!',
'Testing 123',
'Special chars: !@#$%^&*()',
'Unicode: δ½ ε₯½οΌδΈη',
'', // empty string
' ', // space only
'Multi\nline\ntext',
'JSON: {"key": "value"}',
];
for (const marker of [...EMOJI_LIST, ...ALPHABET_LIST]) {
for (const str of testStrings) {
const encoded = encode(marker, str);
const decoded = decode(encoded);
// Ensure decoding returns the original string
expect(decoded).toBe(str);
// Ensure encoded string starts with marker
expect(encoded.startsWith(marker)).toBe(true);
}
}
});
test('toVariationSelector should map bytes correctly', () => {
// Test bytes 0-15
for (let i = 0; i < 16; i++) {
const selector = toVariationSelector(i);
expect(selector).not.toBeNull();
expect(selector?.codePointAt(0)).toBe(0xfe00 + i);
}
// Test bytes 16-255
for (let i = 16; i < 256; i++) {
const selector = toVariationSelector(i);
expect(selector).not.toBeNull();
expect(selector?.codePointAt(0)).toBe(0xe0100 + (i - 16));
}
// Test invalid byte
expect(toVariationSelector(256)).toBeNull();
expect(toVariationSelector(-1)).toBeNull();
});
test('fromVariationSelector should reverse mapping correctly', () => {
// Test variation selectors 0xFE00-0xFE0F
for (let i = 0; i < 16; i++) {
const byte = fromVariationSelector(0xfe00 + i);
expect(byte).toBe(i);
}
// Test variation selectors supplement 0xE0100-0xE01EF
for (let i = 16; i < 256; i++) {
const byte = fromVariationSelector(0xe0100 + (i - 16));
expect(byte).toBe(i);
}
// Test invalid code points
expect(fromVariationSelector(0xfdfe)).toBeNull();
expect(fromVariationSelector(0xe01f0)).toBeNull();
});
test('should handle empty string encoding', () => {
const encoded = encode('π₯', '');
expect(encoded).toBe('π₯');
const decoded = decode(encoded);
expect(decoded).toBe('');
});
test('should handle very long strings', () => {
const longString = 'A'.repeat(10000);
const encoded = encode('π₯', longString);
const decoded = decode(encoded);
expect(decoded).toBe(longString);
});
test('should handle binary data', () => {
// Create binary-like string
const binaryString = String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i));
const encoded = encode('π₯', binaryString);
const decoded = decode(encoded);
expect(decoded).toBe(binaryString);
});
});
Part 7: Common Pitfalls
1. β Not handling UTF-8 correctly
Problem: Using charCodeAt() instead of TextEncoder/TextDecoder breaks Unicode characters.
Solution: Always use TextEncoder/TextDecoder:
// β Wrong
const bytes = Array.from(text).map(c => c.charCodeAt(0));
// β
Correct
const bytes = new TextEncoder().encode(text);
2. β Not using codePointAt() for multi-byte characters
Problem: Using charCodeAt() doesn't handle Unicode characters outside the Basic Multilingual Plane.
Solution: Use codePointAt():
// β Wrong
const codePoint = char.charCodeAt(0);
// β
Correct
const codePoint = char.codePointAt(0);
3. β Not handling empty strings
Problem: Empty string encoding/decoding can cause errors.
Solution: Handle empty strings explicitly:
export function encode(marker: string, text: string): string {
if (text === '') {
return marker;
}
// ... rest of encoding
}
export function decode(text: string): string {
// Skip marker, check if any variation selectors exist
const chars = Array.from(text);
let hasVariationSelectors = false;
// ... check for variation selectors
if (!hasVariationSelectors) {
return '';
}
// ... rest of decoding
}
4. β Not stopping at first non-variation-selector
Problem: Decoding continues through regular text after encoded data.
Solution: Stop decoding at first non-variation-selector:
for (const char of chars) {
const byte = fromVariationSelector(char.codePointAt(0)!);
if (byte === null && decoded.length > 0) {
break; // Stop at first non-variation-selector
}
// ... continue decoding
}
5. β Copying encoded text loses invisible characters
Problem: Some applications strip invisible Unicode characters when copying.
Solution:
- Use
navigator.clipboard.writeText()for reliable copying - Warn users that some apps may strip invisible characters
- Provide alternative sharing methods (QR code, direct link)
6. β Not validating before decoding
Problem: Decoding invalid text causes errors.
Solution: Validate before decoding:
try {
const decoded = decode(text);
if (!decoded || decoded.length === 0) {
throw new Error('No encoded data found');
}
return decoded;
} catch (error) {
console.error('Decoding failed:', error);
return undefined;
}
Part 8: Advanced Usage
Custom Marker Detection
export function detectMarker(text: string): string | null {
const chars = Array.from(text);
if (chars.length === 0) return null;
// First character is likely the marker
const firstChar = chars[0];
// Check if rest are variation selectors
let hasVariationSelectors = false;
for (let i = 1; i < chars.length; i++) {
const codePoint = chars[i].codePointAt(0);
if (codePoint === undefined) continue;
if (
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
) {
hasVariationSelectors = true;
break;
}
}
return hasVariationSelectors ? firstChar : null;
}
Batch Encoding/Decoding
export function encodeBatch(marker: string, texts: string[]): string[] {
return texts.map(text => encode(marker, text));
}
export function decodeBatch(encodedTexts: string[]): string[] {
return encodedTexts.map(text => {
try {
return decode(text);
} catch {
return '';
}
});
}
URL-Safe Encoding
export function encodeToUrl(marker: string, text: string): string {
const encoded = encode(marker, text);
return encodeURIComponent(encoded);
}
export function decodeFromUrl(encodedUrl: string): string {
try {
const decoded = decodeURIComponent(encodedUrl);
return decode(decoded);
} catch {
throw new Error('Invalid URL-encoded data');
}
}
Security Considerations
Steganography, not encryption - This is steganography (hiding data), not encryption (securing data). The encoded data can be decoded by anyone who knows the technique.
Invisible characters - Some applications may strip invisible Unicode characters. Test in your target applications.
Character limits - Some platforms have character limits that may truncate encoded messages.
Copy/paste reliability - Not all applications preserve invisible characters when copying. Test thoroughly.
Detection - Encoded text can be detected by checking for variation selectors. This is not secure against determined adversaries.
Use Cases
1. Steganographic Messaging
Hide messages in plain sight using emojis or letters as markers.
2. Watermarking
Embed metadata invisibly in text content.
3. Cashu Token Encoding
Encode Cashu tokens into emojis for easy sharing (peanut emoji marker).
4. Nostr Integration
Encode Nostr event IDs or other data invisibly in messages.
5. QR Code Alternative
Encode data into text that can be copied/pasted instead of QR codes.
Verification Checklist
- Encoding/decoding works for all UTF-8 characters
- Empty strings handled correctly
- Very long strings handled correctly
- Binary data handled correctly
- Multiple markers work correctly
- Detection function works correctly
- Error handling is graceful
- React hooks work correctly
- UI components are accessible
- Copy-to-clipboard works reliably
- Tests cover edge cases
Summary
To implement emoji encoding:
- Core functions - Implement
encode()anddecode()using Unicode Variation Selectors - React hooks - Create
useEmojiEncoding()hook for easy integration - UI components - Build encoder/decoder interface with emoji/character selector
- Detection - Add
isEncoded()function to detect encoded text - Error handling - Validate inputs and handle errors gracefully
- Testing - Test with various text types, Unicode characters, and edge cases
Key principle: Use Unicode Variation Selectors (U+FE00-U+FE0F and U+E0100-U+E01EF) to invisibly encode UTF-8 bytes, making any character (emoji, letter, symbol) a potential marker for steganographic encoding.