Skip to main content

Building a Preference Center

Custom UI

Create custom consent preference UIs using SDK hooks. Full control over styling while maintaining compliance.

Full Customization

Build your own UI with complete design control

React Hooks

Access consent state with useConsent hook

Cross-Device Sync

Preferences sync across all user devices

Accessible

WCAG 2.1 compliant patterns included

Building a Custom Preference Center

Here's a complete example of a custom preference center with granular controls:

components/preference-center.tsx
'use client'

import { useConsent } from '@sylphx/sdk/react'
import { useState } from 'react'

export function PreferenceCenter() {
  const {
    consentTypes,
    consents,
    setConsents,
    acceptAll,
    declineOptional,
    gpcEnabled,
    lastUpdated,
    isLoading,
  } = useConsent()

  const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({})
  const [isSaving, setIsSaving] = useState(false)

  // Track local changes before saving
  const handleToggle = (categoryId: string, value: boolean) => {
    setPendingChanges(prev => ({ ...prev, [categoryId]: value }))
  }

  // Get current value (pending change or saved)
  const getValue = (categoryId: string) => {
    return pendingChanges[categoryId] ?? consents[categoryId] ?? false
  }

  // Save all pending changes
  const handleSave = async () => {
    if (Object.keys(pendingChanges).length === 0) return

    setIsSaving(true)
    try {
      await setConsents(pendingChanges)
      setPendingChanges({})
    } finally {
      setIsSaving(false)
    }
  }

  // Check if there are unsaved changes
  const hasChanges = Object.keys(pendingChanges).length > 0

  if (isLoading) {
    return <div className="animate-pulse">Loading preferences...</div>
  }

  return (
    <div className="max-w-2xl mx-auto p-6 bg-background rounded-xl border border-border">
      {/* Header */}
      <div className="mb-6">
        <h2 className="text-2xl font-bold text-foreground">Privacy Preferences</h2>
        <p className="text-muted-foreground mt-1">
          Manage how we use your data. Changes take effect immediately.
        </p>
      </div>

      {/* GPC Notice */}
      {gpcEnabled && (
        <div className="mb-6 p-4 bg-success/10 border border-success/30 rounded-lg">
          <div className="flex items-center gap-2">
            <Shield className="w-5 h-5 text-success" />
            <span className="font-medium text-success">Global Privacy Control Detected</span>
          </div>
          <p className="text-sm text-muted-foreground mt-1">
            Your browser has GPC enabled. We respect this signal and have limited data sharing.
          </p>
        </div>
      )}

      {/* Quick Actions */}
      <div className="flex gap-3 mb-6">
        <button
          onClick={acceptAll}
          className="flex-1 py-2 px-4 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
        >
          Accept All
        </button>
        <button
          onClick={declineOptional}
          className="flex-1 py-2 px-4 border border-border rounded-lg font-medium hover:bg-muted transition-colors"
        >
          Essential Only
        </button>
      </div>

      {/* Category List */}
      <div className="space-y-4">
        {consentTypes.map((type) => (
          <div
            key={type.id}
            className={`p-4 rounded-lg border transition-colors ${
              type.required
                ? 'border-border bg-muted/50'
                : 'border-border hover:border-primary/30'
            }`}
          >
            <div className="flex items-start justify-between">
              <div className="flex-1">
                <div className="flex items-center gap-2">
                  <h3 className="font-semibold text-foreground">{type.name}</h3>
                  {type.required && (
                    <span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
                      Required
                    </span>
                  )}
                </div>
                <p className="text-sm text-muted-foreground mt-1">{type.description}</p>

                {/* Show affected cookies/scripts */}
                {type.cookies && type.cookies.length > 0 && (
                  <div className="mt-2">
                    <span className="text-xs text-muted-foreground">Cookies: </span>
                    <span className="text-xs font-mono text-muted-foreground">
                      {type.cookies.join(', ')}
                    </span>
                  </div>
                )}
              </div>

              {/* Toggle Switch */}
              <button
                role="switch"
                aria-checked={getValue(type.id)}
                aria-label={`${type.name} consent`}
                disabled={type.required}
                onClick={() => handleToggle(type.id, !getValue(type.id))}
                className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
                  getValue(type.id)
                    ? 'bg-primary'
                    : 'bg-muted'
                } ${type.required ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
              >
                <span
                  className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
                    getValue(type.id) ? 'translate-x-6' : 'translate-x-1'
                  }`}
                />
              </button>
            </div>
          </div>
        ))}
      </div>

      {/* Save Button */}
      {hasChanges && (
        <div className="mt-6 flex items-center justify-between p-4 bg-muted rounded-lg">
          <span className="text-sm text-muted-foreground">You have unsaved changes</span>
          <button
            onClick={handleSave}
            disabled={isSaving}
            className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
          >
            {isSaving ? 'Saving...' : 'Save Preferences'}
          </button>
        </div>
      )}

      {/* Last Updated */}
      {lastUpdated && (
        <p className="mt-4 text-xs text-muted-foreground text-center">
          Last updated: {new Date(lastUpdated).toLocaleDateString()}
        </p>
      )}
    </div>
  )
}

Styling the Preference UI

Create different visual styles for your preference center to match your brand:

// Minimal, list-based preference UI
export function MinimalPreferences() {
  const { consentTypes, setConsent } = useConsent()

  return (
    <div className="divide-y divide-border">
      {consentTypes.map((type) => (
        <label
          key={type.id}
          className="flex items-center justify-between py-4 cursor-pointer"
        >
          <div>
            <span className="font-medium text-foreground">{type.name}</span>
            <p className="text-sm text-muted-foreground">{type.description}</p>
          </div>
          <input
            type="checkbox"
            checked={type.granted}
            disabled={type.required}
            onChange={(e) => setConsent(type.id, e.target.checked)}
            className="h-5 w-5 rounded border-border text-primary focus:ring-primary"
          />
        </label>
      ))}
    </div>
  )
}

Syncing Preferences Across Devices

When users are authenticated, their consent preferences automatically sync across all their devices:

Cross-Device Sync
'use client'

import { useConsent, useAuth } from '@sylphx/sdk/react'
import { useEffect } from 'react'

export function ConsentProvider({ children }) {
  const { user, isAuthenticated } = useAuth()
  const { syncWithServer, consents } = useConsent()

  useEffect(() => {
    if (isAuthenticated && user) {
      // When user logs in, sync consent from server
      // This ensures preferences set on one device apply everywhere
      syncWithServer({
        userId: user.id,
        // Strategy for handling conflicts:
        // - 'server': Server preferences override local
        // - 'local': Local preferences override server
        // - 'merge': Most restrictive wins (recommended for privacy)
        conflictStrategy: 'merge',
      })
    }
  }, [isAuthenticated, user, syncWithServer])

  return <>{children}</>
}

// On the server, preferences are automatically stored with the user
// The SDK handles syncing transparently:
//
// 1. User sets preferences on Desktop -> stored locally + server
// 2. User logs in on Mobile -> local synced from server
// 3. User changes preference on Mobile -> updated locally + server
// 4. Desktop automatically receives update on next page load
Manual Server Sync
import { platform } from '@/lib/platform'

// Manually sync preferences to server (for authenticated users)
export async function syncConsentToServer(userId: string, consents: Record<string, boolean>) {
  await platform.consent.setConsents({
    userId,
    consents,
    // Include metadata for audit trail
    metadata: {
      timestamp: new Date().toISOString(),
      source: 'preference_center',
      userAgent: navigator.userAgent,
    },
  })
}

// Fetch preferences from server (on login)
export async function fetchConsentFromServer(userId: string) {
  const serverConsents = await platform.consent.getUserConsents(userId)

  // Returns:
  // {
  //   consents: { analytics: true, marketing: false, ... },
  //   lastUpdated: '2024-01-15T10:30:00Z',
  //   gpcEnabled: false,
  // }

  return serverConsents
}

Anonymous Users

For users who haven't logged in, preferences are stored in a cookie/localStorage. When they create an account or log in, these preferences are automatically migrated to their user profile.

Best Practices

Clear Category Descriptions

Use plain language to explain what each category does. Avoid technical jargon.

Immediate Feedback

Show visual confirmation when preferences are saved. Use optimistic updates for better UX.

Respect User Choices

Never pre-select non-essential categories. Always honor opt-out preferences.

Easy Access

Make the preference center accessible from footer links, account settings, and the cookie banner.

Mobile-Friendly

Ensure the preference center works well on all screen sizes. Use touch-friendly controls.

Show Affected Items

List the specific cookies and scripts affected by each category for transparency.

Accessibility Considerations

Ensure your preference center is accessible to all users, including those using assistive technologies:

Accessible Toggle Component
'use client'

interface AccessibleToggleProps {
  id: string
  label: string
  description: string
  checked: boolean
  disabled?: boolean
  onChange: (checked: boolean) => void
}

export function AccessibleToggle({
  id,
  label,
  description,
  checked,
  disabled,
  onChange,
}: AccessibleToggleProps) {
  const descriptionId = `${id}-description`

  return (
    <div className="flex items-start justify-between gap-4">
      <div className="flex-1">
        <label
          htmlFor={id}
          className="font-medium text-foreground cursor-pointer"
        >
          {label}
        </label>
        <p id={descriptionId} className="text-sm text-muted-foreground">
          {description}
        </p>
      </div>

      <button
        id={id}
        role="switch"
        aria-checked={checked}
        aria-describedby={descriptionId}
        aria-disabled={disabled}
        disabled={disabled}
        onClick={() => onChange(!checked)}
        onKeyDown={(e) => {
          // Support Space and Enter keys
          if (e.key === ' ' || e.key === 'Enter') {
            e.preventDefault()
            if (!disabled) onChange(!checked)
          }
        }}
        className={`
          relative inline-flex h-6 w-11 items-center rounded-full
          transition-colors duration-200 ease-in-out
          focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
          ${checked ? 'bg-primary' : 'bg-muted'}
          ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
        `}
      >
        <span className="sr-only">
          {checked ? 'Disable' : 'Enable'} {label}
        </span>
        <span
          aria-hidden="true"
          className={`
            inline-block h-4 w-4 transform rounded-full bg-white shadow
            transition-transform duration-200 ease-in-out
            ${checked ? 'translate-x-6' : 'translate-x-1'}
          `}
        />
      </button>
    </div>
  )
}

Accessibility Checklist

  • Use semantic HTML (buttons, labels)
  • Add ARIA attributes for switches
  • Ensure keyboard navigation works
  • Provide visible focus indicators
  • Use sufficient color contrast
  • Include descriptive labels
  • Announce state changes to screen readers
  • Test with assistive technologies

Screen Reader Announcements

Use aria-live regions to announce when preferences are saved or when errors occur. This ensures screen reader users receive feedback about their actions.

Need pre-built components?

Use our ready-made CookieBanner and ConsentPreferences components for faster implementation.

View Components