📖 Guía paso a paso

Manual Pokédex
con Angular 21

Construye una Pokédex completa con búsqueda en tiempo real, favoritos persistentes, modal de detalles y cadena de evoluciones, usando Angular standalone, Tailwind CSS y la PokeAPI.

Stack: Angular 21 + Tailwind + PokeAPI
Duración: 4–6 horas
🎯 Nivel: Principiante
📦 16 secciones

01 Prerrequisitos

Descarga e instala Node.js LTS desde: https://nodejs.org

Verifica en la terminal que tengas las versiones correctas:

node --version
npm --version

Instala el CLI de Angular globalmente:

npm install -g @angular/cli
ng version
💡
Tip — VS Code Instala la extensión Angular Language Service para autocompletado inteligente de templates, detección de errores en tiempo real y navegación por el código.

02 Crear el proyecto Angular

2.1 — Crear el proyecto

ng new pokedex-angular --standalone --style=css --routing=false --skip-git

Confirma las opciones cuando el CLI lo solicite:

  • Would you like to add Angular routing? → No
  • Which stylesheet format? → CSS

2.2 — Entrar al proyecto

cd pokedex-angular

2.3 — Verificar que funciona

ng serve

Abre http://localhost:4200 en tu navegador. Deberías ver la página de bienvenida de Angular.

2.4 — Instalar dependencias extra

npm install @angular/animations

Verifica que package.json incluya las dependencias de Angular 21, RxJS y TypeScript 5.9+.

03 Configurar Tailwind CSS

3.1 — Instalar Tailwind y PostCSS

npm install -D tailwindcss postcss autoprefixer

3.2 — Crear archivo de configuración

npx tailwindcss init

Reemplaza el contenido de tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{html,ts}"],
  theme: {
    extend: {
      fontFamily: {
        sans: ['Inter', 'system-ui', 'sans-serif'],
      },
      animation: {
        'heart-beat': 'heartBeat 1.2s ease-in-out infinite',
      },
      keyframes: {
        heartBeat: {
          '0%, 100%': { transform: 'scale(1)' },
          '50%': { transform: 'scale(1.2)' },
        },
      },
    },
  },
  plugins: [],
}

3.3 — Crear archivo PostCSS

Crea postcss.config.js en la raíz del proyecto:

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

3.4 — Importar Google Fonts

En src/index.html, dentro del <head>:

<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">

3.5 — Configurar estilos globales

En src/styles.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: 'Inter', system-ui, sans-serif;
  background: radial-gradient(ellipse at center,
    #2d1b4e 0%, #1a1a2e 50%, #0f0c29 100%);
  min-height: 100vh;
  overflow-x: hidden;
}

.glass {
  background: rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(16px);
  border: 1px solid rgba(255, 255, 255, 0.15);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

.skeleton {
  background: linear-gradient(90deg,
    rgba(255,255,255,0.05) 25%,
    rgba(255,255,255,0.15) 50%,
    rgba(255,255,255,0.05) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
::-webkit-scrollbar-thumb {
  background: rgba(147, 51, 234, 0.5);
  border-radius: 4px;
}

04 Planificación de la estructura

src/app/
├── app.component.ts ← Controlador principal
├── app.component.html ← Interfaz principal
├── app.component.css ← (vacío, usamos Tailwind)
├── app.config.ts ← Proveedores globales
├── models/
│ └── pokemon.model.ts ← Interfaces / Types
└── services/
└── pokemon.service.ts ← Lógica de datos y API
ℹ️
Standalone components — En Angular 17+ ya no necesitas módulos NgModule. Los componentes se declaran con standalone: true e importan directamente los módulos que necesitan (CommonModule, FormsModule, etc.).

05 Crear los modelos / interfaces

Crea el archivo src/app/models/pokemon.model.ts:

export interface Pokemon {
  id: number;
  name: string;
  sprites: {
    front_default: string;
    other: { 'official-artwork': { front_default: string } };
  };
  types: PokemonType[];
  height: number;
  weight: number;
  abilities: Ability[];
  stats: Stat[];
}

export interface PokemonType {
  slot: number;
  type: { name: string; url: string };
}

export interface Ability {
  ability: { name: string; url: string };
  is_hidden: boolean;
  slot: number;
}

export interface Stat {
  base_stat: number;
  stat: { name: string; url: string };
}

export interface PokemonListItem { name: string; url: string; }
export interface PokemonListResponse { count: number; results: PokemonListItem[]; }

export interface PokemonSpecies {
  id: number;
  name: string;
  evolution_chain: { url: string };
  habitat: { name: string } | null;
  flavor_text_entries: FlavorTextEntry[];
  generation: { name: string };
}

export interface FlavorTextEntry {
  flavor_text: string;
  language: { name: string };
}

export interface EvolutionChain { id: number; chain: EvolutionNode; }

export interface EvolutionNode {
  species: { name: string; url: string };
  evolution_details: EvolutionDetail[];
  evolves_to: EvolutionNode[];
}

export interface EvolutionChainDisplay {
  name: string;
  id: number;
  sprites: { front_default: string };
  types: PokemonType[];
}

export interface EvolutionChainParsed {
  id: number;
  chain: EvolutionChainDisplay[];
}
💡
¿Para qué sirven las interfaces? Le dicen a TypeScript cuál es la forma exacta de los datos. Así obtienes autocompletado al escribir pokemon. y recibes errores si accedes a una propiedad que no existe.

06 Crear el servicio de Pokémon

Crea src/app/services/pokemon.service.ts. Este servicio centraliza toda la lógica de datos:

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map, switchMap } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class PokemonService {
  private readonly http = inject(HttpClient);
  private readonly BASE_URL = 'https://pokeapi.co/api/v2';
  private readonly FAVORITES_KEY = 'pokedex_favorites';

  // ── PETICIONES A LA API ──────────────────────────
  getPokemonList(offset = 0, limit = 151): Observable<PokemonListItem[]> {
    const params = new HttpParams()
      .set('offset', offset.toString())
      .set('limit', limit.toString());
    return this.http.get<PokemonListResponse>(`${this.BASE_URL}/pokemon`, { params })
      .pipe(map(r => r.results));
  }

  getPokemon(nameOrId: string | number): Observable<Pokemon> {
    return this.http.get<Pokemon>(`${this.BASE_URL}/pokemon/${nameOrId}`);
  }

  getPokemonSpecies(nameOrId: string | number): Observable<PokemonSpecies> {
    return this.http.get<PokemonSpecies>(`${this.BASE_URL}/pokemon-species/${nameOrId}`);
  }

  getEvolutionChain(url: string): Observable<EvolutionChain> {
    return this.http.get<EvolutionChain>(url);
  }

  // ── DATOS COMBINADOS ─────────────────────────────
  getPokemonWithEvolutionChain(nameOrId: string | number) {
    return this.getPokemon(nameOrId).pipe(
      switchMap(pokemon =>
        this.getPokemonSpecies(nameOrId).pipe(
          switchMap(species =>
            this.getEvolutionChain(species.evolution_chain.url).pipe(
              map(chain => ({
                pokemon,
                evolutionChain: this.parseEvolutionChain(chain)
              }))
            )
          )
        )
      )
    );
  }

  // ── FAVORITOS (localStorage) ──────────────────────
  getFavorites(): number[] {
    return JSON.parse(localStorage.getItem(this.FAVORITES_KEY) || '[]');
  }

  toggleFavorite(id: number): boolean {
    const favs = this.getFavorites();
    if (favs.includes(id)) {
      localStorage.setItem(this.FAVORITES_KEY,
        JSON.stringify(favs.filter(f => f !== id)));
      return false;
    }
    favs.push(id);
    localStorage.setItem(this.FAVORITES_KEY, JSON.stringify(favs));
    return true;
  }

  // ── UTILIDADES ────────────────────────────────────
  getPokemonImage(id: number): string {
    return `https://raw.githubusercontent.com/PokeAPI/sprites/master/` +
           `sprites/pokemon/other/official-artwork/${id}.png`;
  }

  getTypeColor(type: string): string {
    const colors: Record<string, string> = {
      fire: '#f97316',    water: '#3b82f6',    grass: '#22c55e',
      electric: '#eab308', psychic: '#ec4899', ice: '#67e8f9',
      dragon: '#8b5cf6',  dark: '#374151',     normal: '#9ca3af',
      poison: '#a855f7',  ground: '#d97706',   flying: '#60a5fa',
      bug: '#84cc16',     rock: '#78716c',     ghost: '#6d28d9',
      steel: '#6b7280',   fighting: '#dc2626', fairy: '#f472b6',
    };
    return colors[type] ?? '#6b7280';
  }
}

Configurar los proveedores en app.config.ts

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideAnimations()
  ]
};

07 Diseñar el layout principal

main.ts — punto de entrada

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [...appConfig.providers]
}).catch(err => console.error(err));

Estructura de app.component.html

El template principal tiene cinco grandes bloques:

  1. Fondo decorativo — círculos con blur y gradientes usando Tailwind
  2. Header glassmorphism — logo Pokéball, buscador con debounce, botón "Favoritos"
  3. Grid de Pokémon — componente <app-pokemon-grid>
  4. Modal de detalles — componente <app-pokemon-modal>
  5. Footer — créditos de PokeAPI
ℹ️
Glassmorphism — Se logra con backdrop-filter: blur() + fondo semitransparente + borde blanco tenue. Requiere que haya contenido visual detrás del elemento.

08 Mostrar la lista de Pokémon en cards

Genera el componente con el CLI:

ng generate component pokemon-grid

El componente recibe datos del padre mediante @Input() y emite eventos con @Output():

@Input() pokemonList: Pokemon[] = [];
@Input() loading = false;
@Input() favorites = new Set<number>();

@Output() selectPokemon = new EventEmitter<Pokemon>();
@Output() toggleFavorite = new EventEmitter<number>();

Cada card muestra:

  • Imagen del sprite oficial (high-res)
  • Nombre y número formateado (#001)
  • Tipos con sus colores característicos
  • Altura y peso
  • Lista de habilidades
  • Botón de favorito (❤️ / 🤍) animado
  • Skeleton loading mientras los datos llegan
💡
Skeleton loading — Muestra placeholders con la animación shimmer mientras se esperan los datos. Mejora la UX porque el usuario ve estructura antes del contenido real.

09 Implementar la barra de búsqueda

Usa un BehaviorSubject con operadores RxJS para evitar llamadas excesivas mientras el usuario escribe:

private searchSubject = new BehaviorSubject<string>('');
private destroy$ = new Subject<void>();

setupSearch(): void {
  this.searchSubject.pipe(
    debounceTime(500),        // espera 500ms tras el último keystroke
    distinctUntilChanged(),   // solo emite si el valor cambió
    takeUntil(this.destroy$)  // limpieza al destruir el componente
  ).subscribe(() => this.applyFilter());
}

applyFilter(): void {
  const q = this.searchValue.toLowerCase().trim();
  this.filteredPokemon = this.pokemonList.filter(p => {
    const matchFav = this.searchMode === 'favorites'
      ? this.favorites.has(p.id) : true;
    const matchSearch = q ? p.name.includes(q) : true;
    return matchFav && matchSearch;
  });
}
ℹ️
Búsqueda local — La búsqueda filtra el array ya cargado en memoria. Para 151 Pokémon esto es instantáneo y no genera peticiones adicionales a la API.

10 Agregar favoritos con localStorage

La lógica vive en el servicio (sección 6). En el componente principal cárgalos como un Set para búsquedas O(1):

// Al inicializar
this.favorites = new Set(this.pokemonService.getFavorites());

// Al hacer toggle desde el grid
onToggleFavorite(id: number): void {
  const isNowFavorite = this.pokemonService.toggleFavorite(id);
  if (isNowFavorite) {
    this.favorites.add(id);
  } else {
    this.favorites.delete(id);
  }
  // forzar detección de cambios si usas OnPush
  this.favorites = new Set(this.favorites);
}
💡
¿Por qué un Set? Comprobar si un número existe en un Set es O(1), mientras que en un Array sería O(n). Con 151 Pokémon no hay diferencia notable, pero es buena práctica.

11 Crear el modal de detalles

ng generate component pokemon-modal

Inputs que recibe:

  • pokemon: Pokemon — datos completos del Pokémon seleccionado
  • evolutionChain: EvolutionChainParsed | null — cadena de evolución
  • loadingEvolutions: boolean — estado de carga de evoluciones

Outputs que emite:

  • closeModal — cierra el modal
  • goToPokemon(id: number) — carga otro Pokémon desde evoluciones

Contenido del modal:

  • Imagen grande con gradiente del tipo principal
  • Altura, peso, tipos, habilidades
  • Barras de estadísticas con colores por stat y animación
  • Cadena de evoluciones con flechas
⚠️
Cerrar el modal — El click en el backdrop debe cerrar el modal, pero el click dentro del contenido NO debe propagarse. Usa (click)="closeModal.emit()" en el backdrop y (click)="$event.stopPropagation()" en el contenido.

12 Mostrar la cadena de evoluciones

La API devuelve un árbol recursivo. Usa una función recursiva para aplanarlo en un array lineal:

parseEvolutionChain(chain: EvolutionChain): EvolutionChainParsed {
  const parseNode = (node: EvolutionNode): EvolutionChainDisplay[] => {
    const idStr = node.species.url.split('/').filter(Boolean).pop() ?? '0';
    const id = parseInt(idStr, 10);

    const current: EvolutionChainDisplay = {
      name: node.species.name,
      id,
      sprites: { front_default: this.getPokemonImage(id) },
      types: []
    };

    const rest = node.evolves_to.flatMap(child => parseNode(child));
    return [current, ...rest];
  };

  return { id: chain.id, chain: parseNode(chain.chain) };
}

En el template, muestra cada eslabón con flechas entre ellos:

<div *ngFor="let evo of evolutionChain.chain; let last = last">
  <app-evolution-card [pokemon]="evo" (click)="goToPokemon.emit(evo.id)"></app-evolution-card>
  <span *ngIf="!last" class="arrow">→</span>
</div>

13 Pulir estilos y animaciones

Glassmorphism

.glass {
  background: rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(16px);
  border: 1px solid rgba(255, 255, 255, 0.15);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

Hover en cards (Tailwind)

<div class="transition-all duration-300
            hover:-translate-y-2 hover:shadow-2xl
            cursor-pointer">

Barras de estadísticas animadas

/* En styles.css */
@keyframes slideIn {
  from { width: 0%; }
  to   { width: var(--stat-width); }
}

.stat-bar {
  animation: slideIn 0.8s ease-out forwards;
}
<!-- En el template -->
<div class="stat-bar rounded-full h-2"
     [style.--stat-width]="(stat.base_stat / 255 * 100) + '%'"
     [style.background]="getStatColor(stat.stat.name)">
</div>

Fuente + scrollbar

/* Importar en index.html */
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
      rel="stylesheet">

/* Scrollbar personalizado */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb {
  background: rgba(147, 51, 234, 0.5);
  border-radius: 4px;
}

14 Probar y compilar

Modo desarrollo

ng serve

Verificar que compila sin errores

ng build

Build de producción optimizado

ng build --configuration production

Los archivos de producción estarán en dist/pokedex-angular/, listos para subir a cualquier hosting estático (Netlify, Vercel, GitHub Pages, etc.).

Tests unitarios (opcional)

ng test

15 Errores comunes y soluciones

ErrorCausaSolución
Can't bind to 'X' Componente hijo no importado Agrega el componente al array imports del componente padre
NullInjectorError: No provider for HttpClient Falta el proveedor Agrega provideHttpClient() en app.config.ts
Property 'X' does not exist on type 'HTMLElement' TypeScript estricto Usa $any($event.target) o un cast explícito
Las imágenes no cargan URL incorrecta Verifica que getPokemonImage() recibe un ID numérico válido
combineLatest no emite Un observable nunca emite Asegúrate de que todos los observables del array emitan al menos un valor
Tailwind no aplica estilos Contenido mal configurado Revisa que content en tailwind.config.js incluye ./src/**/*.{html,ts}

16 Resumen del flujo de la app

Usuario abre la app en el navegador
main.ts llama a bootstrapApplication(AppComponent)
AppComponent.ngOnInit() se ejecuta
loadFavorites() desde localStorage · loadPokemonList() → API · setupSearch() → BehaviorSubject
151 peticiones paralelas a PokeAPI
forkJoin() o peticiones individuales por cada nombre de la lista
Grid de cards visible
Skeleton → datos reales · hover y animaciones activas
Usuario busca o filtra por favoritos
debounceTime(500)applyFilter() sobre el array local (sin red)
Click en una card → getPokemonWithEvolutionChain()
3 peticiones encadenadas: Pokémon → Especie → Cadena de evolución
Modal se abre con stats, habilidades y evoluciones
Click en evolución → carga un nuevo Pokémon en el mismo modal
Toggle de favorito
toggleFavorite(id)localStorage · actualiza el Set de favoritos en memoria