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)
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:
The encoding uses Unicode Variation Selectors to invisibly encode UTF-8 bytes:
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.
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.
// 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;
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:
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:
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:
TextEncoder to convert string to UTF-8 bytesUsage:
const encoded = encode('🥜', 'Hello, World!');
// Result: "🥜" + [invisible characters]
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:
TextDecoder to convert UTF-8 bytes back to stringUsage:
const decoded = decode(encodedText);
// Result: "Hello, World!"
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!');
}
};
}
// 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>
);
}
// 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>
);
}
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:
🥜, 🔐, 😀, 🚀a, Z, α, Ω1, 0, π, ∞!, @, #, $, %, &, *., ,, ;, :, ?, !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:
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');
}
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'
};
}
}
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);
});
});
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);
codePointAt() for multi-byte charactersProblem: 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);
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
}
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
}
Problem: Some applications strip invisible Unicode characters when copying.
Solution:
navigator.clipboard.writeText() for reliable copyingProblem: 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;
}
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;
}
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 '';
}
});
}
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');
}
}
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.
Hide messages in plain sight using emojis or letters as markers.
Embed metadata invisibly in text content.
Encode Cashu tokens into emojis for easy sharing (peanut emoji marker).
Encode Nostr event IDs or other data invisibly in messages.
Encode data into text that can be copied/pasted instead of QR codes.
To implement emoji encoding:
encode() and decode() using Unicode Variation SelectorsuseEmojiEncoding() hook for easy integrationisEncoded() function to detect encoded textKey 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.